--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit e0f1f99cd1c208c5008a936230c38e448dddd45d
Parents : 1212266
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-24T11:29:05-05:00
feat: add native RNode flasher with USB serial support, preload codec2 library, and update backend URL handling
Changes
43 files changed, 3439 insertions(+), 116 deletions(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6a4ef222..4ca6add2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -70,6 +70,9 @@ All notable changes to this project will be documented in this file.
### Fixed
- LXMF outbound progress polling stops on REJECTED (previously only DELIVERED / propagated SENT / FAILED / CANCELLED stopped the loop)
+- Android Codec2: preload libcodec2 via System.loadLibrary and MESHCHAT_NATIVE_LIB_DIR before Chaquopy imports pycodec2
+- Android RNode flasher is a native Activity (USB serial via usb-serial-for-android, ESP32 ROM flash protocol). Tools opens it instead of a WebView serial polyfill
+- Android RNode flasher: Bluetooth permission request returns real status, handles the result callback, and opens app settings when permanently denied
- Web Sync Messages after a backgrounded browser tab: recover stale WebSocket as a shell reconnect, refresh CSRF, and do not abort sync when request-path priming fails
- Conversations: re-opening an already-read thread no longer decrements the Messages unread badge
- Notifications: DND still updates the Messages unread badge (DND only suppresses OS notifications and sound)
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 00a736b9..90509ef4 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -396,6 +396,7 @@ dependencies {
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
implementation 'androidx.webkit:webkit:1.14.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.7'
+ implementation 'com.github.mik3y:usb-serial-for-android:3.8.1'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index f29db13c..de844535 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -90,6 +90,13 @@
android:resource="@xml/device_filter" />
</activity>
+ <activity
+ android:name=".rnode.RNodeFlasherActivity"
+ android:exported="false"
+ android:label="@string/rnode_flasher_title"
+ android:parentActivityName=".MainActivity"
+ android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|density|fontScale|keyboard|keyboardHidden|navigation|uiMode|colorMode|layoutDirection" />
+
<!--
Non-exported: notification answer/decline PendingIntents only.
Keeps forgeable CALL_ANSWER / CALL_DECLINE off the exported MainActivity.
diff --git a/android/app/src/main/assets/rnode_products.json b/android/app/src/main/assets/rnode_products.json
new file mode 100644
index 00000000..e0f0cec9
--- /dev/null
+++ b/android/app/src/main/assets/rnode_products.json
@@ -0,0 +1,521 @@
+[
+ {
+ "name": "Heltec LoRa32 v2",
+ "id": 192,
+ "platform": 128,
+ "firmware_filename": "rnode_firmware_heltec32v2.zip",
+ "flash_config": {
+ "flash_size": "8MB",
+ "flash_files": {
+ "0xe000": "rnode_firmware_heltec32v2.boot_app0",
+ "0x1000": "rnode_firmware_heltec32v2.bootloader",
+ "0x10000": "rnode_firmware_heltec32v2.bin",
+ "0x210000": "console_image.bin",
+ "0x8000": "rnode_firmware_heltec32v2.partitions"
+ }
+ },
+ "models": [
+ {
+ "id": 196,
+ "name": "433 MHz",
+ "mapped_id": null,
+ "flash_config": null,
+ "firmware_filename": null
+ },
+ {
+ "id": 201,
+ "name": "868 MHz / 915 MHz / 923 MHz",
+ "mapped_id": null,
+ "flash_config": null,
+ "firmware_filename": null
+ }
+ ]
+ },
+ {
+ "name": "Heltec LoRa32 v3",
+ "id": 193,
+ "platform": 128,
+ "firmware_filename": "rnode_firmware_heltec32v3.zip",
+ "flash_config": {
+ "flash_size": "8MB",
+ "flash_files": {
+ "0xe000": "rnode_firmware_heltec32v3.boot_app0",
+ "0x0": "rnode_firmware_heltec32v3.bootloader",
+ "0x10000": "rnode_firmware_heltec32v3.bin",
+ "0x210000": "console_image.bin",
+ "0x8000": "rnode_firmware_heltec32v3.partitions"
+ }
+ },
+ "models": [
+ {
+ "id": 197,
+ "name": "433 MHz",
+ "mapped_id": null,
+ "flash_config": null,
+ "firmware_filename": null
+ },
+ {
+ "id": 202,
+ "name": "868 MHz / 915 MHz / 923 MHz",
+ "mapped_id": null,
+ "flash_config": null,
+ "firmware_filename": null
+ }
+ ]
+ },
+ {
+ "name": "Heltec LoRa32 v4",
+ "platform": 128,
+ "firmware_filename": "rnode_firmware_heltec32v4pa.zip",
+ "flash_config": {
+ "flash_size": "16MB",
+ "flash_files": {
+ "0xe000": "rnode_firmware_heltec32v4pa.boot_app0",
+ "0x0": "rnode_firmware_heltec32v4pa.bootloader",
+ "0x10000": "rnode_firmware_heltec32v4pa.bin",
+ "0x210000": "console_image.bin",
+ "0x8000": "rnode_firmware_heltec32v4pa.partitions"
+ }
+ },
+ "models": [
+ {
+ "name": "868 MHz / 915 MHz / 923 MHz with PA",
+ "mapped_id": null,
+ "flash_config": null,
+ "firmware_filename": null
+ }
+ ]
+ },
+ {
+ "name": "LilyGO LoRa32 v1.0",
+ "id": 178,
+ "platform": 128,
+ "firmware_filename": "rnode_firmware_lora32v10.zip",
+ "flash_config": {
+ "flash_size": "4MB",
+ "flash_files": {
+ "0xe000": "rnode_firmware_lora32v10.boot_app0",
+ "0x1000": "rnode_firmware_lora32v10.bootloader",
+ "0x10000": "rnode_firmware_lora32v10.bin",
+ "0x210000": "console_image.bin",
+ "0x8000": "rnode_firmware_lora32v10.partitions"
+ }
+ },
+ "models": [
+ {
+ "id": 186,
+ "name": "433 MHz",
+ "mapped_id": null,
+ "flash_config": null,
+ "firmware_filename": null
+ },
+ {
+ "id": 187,
+ "name": "868 MHz / 915 MHz / 923 MHz",
+ "mapped_id": null,
+ "flash_config": null,
+ "firmware_filename": null
+ }
+ ]
+ },
+ {
+ "name": "LilyGO LoRa32 v2.0",
+ "id": 176,
+ "platform": 128,
+ "firmware_filename": "rnode_firmware_lora32v20.zip",
+ "flash_config": {
+ "flash_size": "4MB",
+ "flash_files": {
+ "0xe000": "rnode_firmware_lora32v20.boot_app0",
+ "0x1000": "rnode_firmware_lora32v20.bootloader",
+ "0x10000": "rnode_firmware_lora32v20.bin",
+ "0x210000": "console_image.bin",
+ "0x8000": "rnode_firmware_lora32v20.partitions"
+ }
+ },
+ "models": [
+ {
+ "id": 179,
+ "name": "433 MHz",
+ "mapped_id": null,
+ "flash_config": null,
+ "firmware_filename": null
+ },
+ {
+ "id": 184,
+ "name": "868 MHz / 915 MHz / 923 MHz",
+ "mapped_id": null,
+ "flash_config": null,
+ "firmware_filename": null
+ }
+ ]
+ },
+ {
+ "name": "LilyGO LoRa32 v2.1",
+ "id": 177,
+ "platform": 128,
+ "flash_config": null,
+ "models": [
+ {
+ "id": 180,
+ "name": "433 MHz",
+ "mapped_id": null,
+ "flash_config": {
+ "flash_size": "4MB",
+ "flash_files": {
+ "0xe000": "rnode_firmware_lora32v21.boot_app0",
+ "0x1000": "rnode_firmware_lora32v21.bootloader",
+ "0x10000": "rnode_firmware_lora32v21.bin",
+ "0x210000": "console_image.bin",
+ "0x8000": "rnode_firmware_lora32v21.partitions"
+ }
+ },
+ "firmware_filename": "rnode_firmware_lora32v21.zip"
+ },
+ {
+ "id": 185,
+ "name": "868/915/923 MHz",
+ "mapped_id": null,
+ "flash_config": {
+ "flash_size": "4MB",
+ "flash_files": {
+ "0xe000": "rnode_firmware_lora32v21.boot_app0",
+ "0x1000": "rnode_firmware_lora32v21.bootloader",
+ "0x10000": "rnode_firmware_lora32v21.bin",
+ "0x210000": "console_image.bin",
+ "0x8000": "rnode_firmware_lora32v21.partitions"
+ }
+ },
+ "firmware_filename": "rnode_firmware_lora32v21.zip"
+ },
+ {
+ "id": 4,
+ "name": "433 MHz, with TCXO",
+ "mapped_id": 180,
+ "flash_config": {
+ "flash_size": "4MB",
+ "flash_files": {
+ "0xe000": "rnode_firmware_lora32v21_tcxo.boot_app0",
+ "0x1000": "rnode_firmware_lora32v21_tcxo.bootloader",
+ "0x10000": "rnode_firmware_lora32v21_tcxo.bin",
+ "0x210000": "console_image.bin",
+ "0x8000": "rnode_firmware_lora32v21_tcxo.partitions"
+ }
+ },
+ "firmware_filename": "rnode_firmware_lora32v21_tcxo.zip"
+ },
+ {
+ "id": 9,
+ "name": "868/915/923 MHz, with TCXO",
+ "mapped_id": 185,
+ "flash_config": {
+ "flash_size": "4MB",
+ "flash_files": {
+ "0xe000": "rnode_firmware_lora32v21_tcxo.boot_app0",
+ "0x1000": "rnode_firmware_lora32v21_tcxo.bootloader",
+ "0x10000": "rnode_firmware_lora32v21_tcxo.bin",
+ "0x210000": "console_image.bin",
+ "0x8000": "rnode_firmware_lora32v21_tcxo.partitions"
+ }
+ },
+ "firmware_filename": "rnode_firmware_lora32v21_tcxo.zip"
+ }
+ ]
+ },
+ {
+ "name": "LilyGO LoRa T3S3",
+ "id": 3,
+ "platform": 128,
+ "flash_config": null,
+ "models": [
+ {
+ "id": 165,
+ "name": "433 MHz (with SX1278 chip)",
+ "mapped_id": null,
+ "flash_config": {
+ "flash_size": "4MB",
+ "flash_files": {
+ "0xe000": "rnode_firmware_t3s3_sx127x.boot_app0",
+ "0x0": "rnode_firmware_t3s3_sx127x.bootloader",
+ "0x10000": "rnode_firmware_t3s3_sx127x.bin",
+ "0x210000": "console_image.bin",
+ "0x8000": "rnode_firmware_t3s3_sx127x.partitions"
+ }
+ },
+ "firmware_filename": "rnode_firmware_t3s3_sx127x.zip"
+ },
+ {
+ "id": 170,
+ "name": "868/915/923 MHz (with SX1276 chip)",
+ "mapped_id": null,
+ "flash_config": {
+ "flash_size": "4MB",
+ "flash_files": {
+ "0xe000": "rnode_firmware_t3s3_sx127x.boot_app0",
+ "0x0": "rnode_firmware_t3s3_sx127x.bootloader",
+ "0x10000": "rnode_firmware_t3s3_sx127x.bin",
+ "0x210000": "console_image.bin",
+ "0x8000": "rnode_firmware_t3s3_sx127x.partitions"
+ }
+ },
+ "firmware_filename": "rnode_firmware_t3s3_sx127x.zip"
+ },
+ {
+ "id": 161,
+ "name": "433 MHz (with SX1268 chip)",
+ "mapped_id": null,
+ "flash_config": {
+ "flash_size": "4MB",
+ "flash_files": {
+ "0xe000": "rnode_firmware_t3s3.boot_app0",
+ "0x0": "rnode_firmware_t3s3.bootloader",
+ "0x10000": "rnode_firmware_t3s3.bin",
+ "0x210000": "console_image.bin",
+ "0x8000": "rnode_firmware_t3s3.partitions"
+ }
+ },
+ "firmware_filename": "rnode_firmware_t3s3.zip"
+ },
+ {
+ "id": 166,
+ "name": "868/915/923 MHz (with SX1262 chip)",
+ "mapped_id": null,
+ "flash_config": {
+ "flash_size": "4MB",
+ "flash_files": {
+ "0xe000": "rnode_firmware_t3s3.boot_app0",
+ "0x0": "rnode_firmware_t3s3.bootloader",
+ "0x10000": "rnode_firmware_t3s3.bin",
+ "0x210000": "console_image.bin",
+ "0x8000": "rnode_firmware_t3s3.partitions"
+ }
+ },
+ "firmware_filename": "rnode_firmware_t3s3.zip"
+ },
+ {
+ "id": 172,
+ "name": "2.4 GHz (with SX1280 chip)",
+ "mapped_id": null,
+ "flash_config": {
+ "flash_size": "4MB",
+ "flash_files": {
+ "0xe000": "rnode_firmware_t3s3_sx1280_pa.boot_app0",
+ "0x0": "rnode_firmware_t3s3_sx1280_pa.bootloader",
+ "0x10000": "rnode_firmware_t3s3_sx1280_pa.bin",
+ "0x210000": "console_image.bin",
+ "0x8000": "rnode_firmware_t3s3_sx1280_pa.partitions"
+ }
+ },
+ "firmware_filename": "rnode_firmware_t3s3_sx1280_pa.zip"
+ }
+ ]
+ },
+ {
+ "name": "LilyGO T-Beam",
+ "id": 224,
+ "platform": 128,
+ "flash_config": null,
+ "models": [
+ {
+ "id": 228,
+ "name": "433 MHz (with SX1278 chip)",
+ "mapped_id": null,
+ "flash_config": {
+ "flash_size": "4MB",
+ "flash_files": {
+ "0xe000": "rnode_firmware_tbeam.boot_app0",
+ "0x1000": "rnode_firmware_tbeam.bootloader",
+ "0x10000": "rnode_firmware_tbeam.bin",
+ "0x210000": "console_image.bin",
+ "0x8000": "rnode_firmware_tbeam.partitions"
+ }
+ },
+ "firmware_filename": "rnode_firmware_tbeam.zip"
+ },
+ {
+ "id": 233,
+ "name": "868/915/923 MHz (with SX1276 chip)",
+ "mapped_id": null,
+ "flash_config": {
+ "flash_size": "4MB",
+ "flash_files": {
+ "0xe000": "rnode_firmware_tbeam.boot_app0",
+ "0x1000": "rnode_firmware_tbeam.bootloader",
+ "0x10000": "rnode_firmware_tbeam.bin",
+ "0x210000": "console_image.bin",
+ "0x8000": "rnode_firmware_tbeam.partitions"
+ }
+ },
+ "firmware_filename": "rnode_firmware_tbeam.zip"
+ },
+ {
+ "id": 227,
+ "name": "433 MHz (with SX1268 chip)",
+ "mapped_id": null,
+ "flash_config": {
+ "flash_size": "4MB",
+ "flash_files": {
+ "0xe000": "rnode_firmware_tbeam_sx1262.boot_app0",
+ "0x1000": "rnode_firmware_tbeam_sx1262.bootloader",
+ "0x10000": "rnode_firmware_tbeam_sx1262.bin",
+ "0x210000": "console_image.bin",
+ "0x8000": "rnode_firmware_tbeam_sx1262.partitions"
+ }
+ },
+ "firmware_filename": "rnode_firmware_tbeam_sx1262.zip"
+ },
+ {
+ "id": 232,
+ "name": "868/915/923 MHz (with SX1262 chip)",
+ "mapped_id": null,
+ "flash_config": {
+ "flash_size": "4MB",
+ "flash_files": {
+ "0xe000": "rnode_firmware_tbeam_sx1262.boot_app0",
+ "0x1000": "rnode_firmware_tbeam_sx1262.bootloader",
+ "0x10000": "rnode_firmware_tbeam_sx1262.bin",
+ "0x210000": "console_image.bin",
+ "0x8000": "rnode_firmware_tbeam_sx1262.partitions"
+ }
+ },
+ "firmware_filename": "rnode_firmware_tbeam_sx1262.zip"
+ }
+ ]
+ },
+ {
+ "name": "LilyGO T-Beam Supreme",
+ "id": 234,
+ "platform": 128,
+ "firmware_filename": "rnode_firmware_tbeam_supreme.zip",
+ "flash_config": {
+ "flash_size": "4MB",
+ "flash_files": {
+ "0xe000": "rnode_firmware_tbeam_supreme.boot_app0",
+ "0x0": "rnode_firmware_tbeam_supreme.bootloader",
+ "0x10000": "rnode_firmware_tbeam_supreme.bin",
+ "0x210000": "console_image.bin",
+ "0x8000": "rnode_firmware_tbeam_supreme.partitions"
+ }
+ },
+ "models": [
+ {
+ "id": 219,
+ "name": "433 MHz (with SX1268 chip)",
+ "mapped_id": null,
+ "flash_config": null,
+ "firmware_filename": null
+ },
+ {
+ "id": 220,
+ "name": "868/915/923 MHz (with SX1262 chip)",
+ "mapped_id": null,
+ "flash_config": null,
+ "firmware_filename": null
+ }
+ ]
+ },
+ {
+ "name": "LilyGO T-Deck",
+ "id": 208,
+ "platform": 128,
+ "firmware_filename": "rnode_firmware_tdeck.zip",
+ "flash_config": {
+ "flash_size": "4MB",
+ "flash_files": {
+ "0xe000": "rnode_firmware_tdeck.boot_app0",
+ "0x0": "rnode_firmware_tdeck.bootloader",
+ "0x10000": "rnode_firmware_tdeck.bin",
+ "0x210000": "console_image.bin",
+ "0x8000": "rnode_firmware_tdeck.partitions"
+ }
+ },
+ "models": [
+ {
+ "id": 212,
+ "name": "433 MHz (with SX1268 chip)",
+ "mapped_id": null,
+ "flash_config": null,
+ "firmware_filename": null
+ },
+ {
+ "id": 217,
+ "name": "868/915/923 MHz (with SX1262 chip)",
+ "mapped_id": null,
+ "flash_config": null,
+ "firmware_filename": null
+ }
+ ]
+ },
+ {
+ "name": "RNode",
+ "id": 3,
+ "platform": 128,
+ "flash_config": null,
+ "models": [
+ {
+ "id": 162,
+ "name": "Handheld v2.1 RNode, 410 - 525 MHz",
+ "mapped_id": null,
+ "flash_config": {
+ "flash_size": "4MB",
+ "flash_files": {
+ "0xe000": "rnode_firmware_ng21.boot_app0",
+ "0x1000": "rnode_firmware_ng21.bootloader",
+ "0x10000": "rnode_firmware_ng21.bin",
+ "0x210000": "console_image.bin",
+ "0x8000": "rnode_firmware_ng21.partitions"
+ }
+ },
+ "firmware_filename": "rnode_firmware_ng21.zip"
+ },
+ {
+ "id": 167,
+ "name": "Handheld v2.1 RNode, 820 - 1020 MHz",
+ "mapped_id": null,
+ "flash_config": {
+ "flash_size": "4MB",
+ "flash_files": {
+ "0xe000": "rnode_firmware_ng21.boot_app0",
+ "0x1000": "rnode_firmware_ng21.bootloader",
+ "0x10000": "rnode_firmware_ng21.bin",
+ "0x210000": "console_image.bin",
+ "0x8000": "rnode_firmware_ng21.partitions"
+ }
+ },
+ "firmware_filename": "rnode_firmware_ng21.zip"
+ },
+ {
+ "id": 161,
+ "name": "Prototype v2.2 RNode, 410 - 525 MHz",
+ "mapped_id": null,
+ "flash_config": {
+ "flash_size": "4MB",
+ "flash_files": {
+ "0xe000": "rnode_firmware_t3s3.boot_app0",
+ "0x0": "rnode_firmware_t3s3.bootloader",
+ "0x10000": "rnode_firmware_t3s3.bin",
+ "0x210000": "console_image.bin",
+ "0x8000": "rnode_firmware_t3s3.partitions"
+ }
+ },
+ "firmware_filename": "rnode_firmware_t3s3.zip"
+ },
+ {
+ "id": 166,
+ "name": "Prototype v2.2 RNode, 820 - 1020 MHz",
+ "mapped_id": null,
+ "flash_config": {
+ "flash_size": "4MB",
+ "flash_files": {
+ "0xe000": "rnode_firmware_t3s3.boot_app0",
+ "0x0": "rnode_firmware_t3s3.bootloader",
+ "0x10000": "rnode_firmware_t3s3.bin",
+ "0x210000": "console_image.bin",
+ "0x8000": "rnode_firmware_t3s3.partitions"
+ }
+ },
+ "firmware_filename": "rnode_firmware_t3s3.zip"
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/android/app/src/main/java/com/meshchatx/LocalhostTrustOkHttpClient.java b/android/app/src/main/java/com/meshchatx/LocalhostTrustOkHttpClient.java
index cd8bef0d..0d557b49 100644
--- a/android/app/src/main/java/com/meshchatx/LocalhostTrustOkHttpClient.java
+++ b/android/app/src/main/java/com/meshchatx/LocalhostTrustOkHttpClient.java
@@ -14,13 +14,13 @@ import okhttp3.OkHttpClient;
* OkHttp for HTTPS to the embedded MeshChatX server on 127.0.0.1 only (self-signed cert, same
* as WebView SSL ignore). Do not use for public hosts.
*/
-final class LocalhostTrustOkHttpClient {
+public final class LocalhostTrustOkHttpClient {
private static OkHttpClient instance;
private LocalhostTrustOkHttpClient() {
}
- static synchronized OkHttpClient get() {
+ public static synchronized OkHttpClient get() {
if (instance == null) {
instance = build();
}
diff --git a/android/app/src/main/java/com/meshchatx/MainActivity.java b/android/app/src/main/java/com/meshchatx/MainActivity.java
index 84b5e8f0..98779631 100644
--- a/android/app/src/main/java/com/meshchatx/MainActivity.java
+++ b/android/app/src/main/java/com/meshchatx/MainActivity.java
@@ -34,6 +34,7 @@ import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest;
import android.webkit.WebSettings;
import android.webkit.JavascriptInterface;
+import android.webkit.SslErrorHandler;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.ImageView;
@@ -43,6 +44,7 @@ import android.widget.Toast;
import androidx.activity.OnBackPressedCallback;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.appcompat.app.AlertDialog;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.core.content.FileProvider;
@@ -75,6 +77,7 @@ public class MainActivity extends AppCompatActivity {
private static final String PREF_UI_THEME = "ui_theme";
private static final String PREF_BLOCK_SCREENSHOTS = "block_screenshots";
private static final String PREF_CLEAR_CLIPBOARD_ON_BACKGROUND = "clear_clipboard_on_background";
+ private static final String PREF_REMOTE_BACKEND_URL = "remote_backend_url";
private static final String THEME_DARK = "dark";
private static final String THEME_LIGHT = "light";
@@ -83,7 +86,6 @@ public class MainActivity extends AppCompatActivity {
private ImageView loadingLogo;
private TextView loadingText;
private TextView errorText;
- private static final String SERVER_URL = "https://127.0.0.1:8000";
private static final int SERVER_PORT = 8000;
private static final int RUNTIME_PERMISSIONS_REQUEST_CODE = 1001;
private static final int WEB_MEDIA_PERMISSION_REQUEST_CODE = 1003;
@@ -122,7 +124,7 @@ public class MainActivity extends AppCompatActivity {
"Opening MeshChatX…",
"Finishing up…"
};
- private static boolean isAllowedWebViewNavigationUri(Uri uri) {
+ private boolean isAllowedWebViewNavigationUri(Uri uri) {
if (uri == null) {
return false;
}
@@ -145,8 +147,10 @@ public class MainActivity extends AppCompatActivity {
if (host == null) {
return false;
}
- String h = host.toLowerCase();
- return "127.0.0.1".equals(h) || "localhost".equals(h) || "[::1]".equals(h) || "::1".equals(h);
+ if (RemoteBackendUrl.isLoopbackHost(host)) {
+ return true;
+ }
+ return RemoteBackendUrl.matchesBackend(uri.toString(), resolveBackendUrl());
}
private void openExternalBrowserUri(Uri uri) {
@@ -207,22 +211,25 @@ public class MainActivity extends AppCompatActivity {
// or keyboard/resize gaps. Prefer last saved UI theme (default dark).
applyShellCanvasTheme(resolvePreferredUiTheme());
webView.setVisibility(android.view.View.INVISIBLE);
- showLoading("Starting MeshChatX…");
+ final boolean remoteBackend = isRemoteBackendMode();
+ showLoading(remoteBackend ? "Connecting to remote backend…" : "Starting MeshChatX…");
- if (!Python.isStarted()) {
+ if (!remoteBackend) {
+ if (!Python.isStarted()) {
+ try {
+ Python.start(new AndroidPlatform(this));
+ } catch (Exception e) {
+ backendFailed = true;
+ showStartupError("MeshChatX Python runtime failed to start:", toStackTrace(e));
+ return;
+ }
+ }
try {
- Python.start(new AndroidPlatform(this));
- } catch (Exception e) {
- backendFailed = true;
- showStartupError("MeshChatX Python runtime failed to start:", toStackTrace(e));
- return;
+ org.able.BLE.setAppContext(this);
+ } catch (Exception ignored) {
+ // BLE class may be unavailable in incomplete builds.
}
}
- try {
- org.able.BLE.setAppContext(this);
- } catch (Exception ignored) {
- // BLE class may be unavailable in incomplete builds.
- }
requestRuntimePermissionsIfNeeded();
WebSettings webSettings = webView.getSettings();
@@ -321,9 +328,18 @@ public class MainActivity extends AppCompatActivity {
@SuppressLint("WebViewClientOnReceivedSslError")
@Override
- public void onReceivedSslError(WebView view, android.webkit.SslErrorHandler handler, android.net.http.SslError error) {
- // Ignore SSL certificate errors for localhost
- handler.proceed();
+ public void onReceivedSslError(WebView view, SslErrorHandler handler, android.net.http.SslError error) {
+ if (handler == null) {
+ return;
+ }
+ String errorUrl = error != null ? error.getUrl() : null;
+ Uri uri = errorUrl != null ? Uri.parse(errorUrl) : null;
+ if (isAllowedWebViewNavigationUri(uri)) {
+ // Local and configured remote backends often use self-signed TLS.
+ handler.proceed();
+ return;
+ }
+ handler.cancel();
}
});
webView.addJavascriptInterface(new MeshChatXAndroidBridge(this), "MeshChatXAndroid");
@@ -528,8 +544,13 @@ public class MainActivity extends AppCompatActivity {
return;
}
- startMeshChatServer();
- scheduleConnectionRetry("Connecting to local server...");
+ if (isRemoteBackendMode()) {
+ webView.loadUrl(resolveBackendUrl());
+ scheduleConnectionRetry("Connecting to remote backend…");
+ } else {
+ startMeshChatServer();
+ scheduleConnectionRetry("Connecting to local server...");
+ }
}
@Override
@@ -618,6 +639,39 @@ public class MainActivity extends AppCompatActivity {
}
}
+ void openAppPermissionSettings() {
+ try {
+ startActivity(
+ new Intent(
+ Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
+ Uri.parse("package:" + getPackageName())
+ )
+ );
+ } catch (ActivityNotFoundException ignored) {
+ Toast.makeText(this, "App settings unavailable", Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ private static final String PREF_BT_PERM_PROMPTED_PREFIX = "bt_perm_prompted_";
+
+ boolean wasBluetoothPermissionDeniedPermanently(String permission) {
+ // After the first prompt, if rationale is false and still denied, treat as permanent.
+ boolean prompted =
+ getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
+ .getBoolean(PREF_BT_PERM_PROMPTED_PREFIX + permission, false);
+ return prompted
+ && ContextCompat.checkSelfPermission(this, permission)
+ != PackageManager.PERMISSION_GRANTED
+ && !ActivityCompat.shouldShowRequestPermissionRationale(this, permission);
+ }
+
+ void markBluetoothPermissionPrompted(String permission) {
+ getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
+ .edit()
+ .putBoolean(PREF_BT_PERM_PROMPTED_PREFIX + permission, true)
+ .apply();
+ }
+
private String[] grantableWebPermissionResources(PermissionRequest request) {
if (request == null) {
return new String[0];
@@ -702,6 +756,30 @@ public class MainActivity extends AppCompatActivity {
pendingWebPermissionRequest = null;
return;
}
+ if (requestCode == RNODE_BLUETOOTH_PERMISSION_REQUEST_CODE) {
+ boolean granted = true;
+ if (grantResults == null || grantResults.length == 0) {
+ granted = false;
+ } else {
+ for (int result : grantResults) {
+ if (result != PackageManager.PERMISSION_GRANTED) {
+ granted = false;
+ break;
+ }
+ }
+ }
+ final boolean ok = granted;
+ if (webView != null) {
+ webView.evaluateJavascript(
+ "window.dispatchEvent(new CustomEvent('meshchatx-android-permission',"
+ + "{detail:{group:'bluetooth',granted:"
+ + ok
+ + "}}));",
+ null
+ );
+ }
+ return;
+ }
if (requestCode != RUNTIME_PERMISSIONS_REQUEST_CODE) {
return;
}
@@ -738,7 +816,91 @@ public class MainActivity extends AppCompatActivity {
}
private boolean isStartupRequest(String url) {
- return url != null && url.startsWith(SERVER_URL);
+ return RemoteBackendUrl.matchesBackend(url, resolveBackendUrl());
+ }
+
+ @Nullable
+ private String resolveStoredRemoteBackendUrl() {
+ SharedPreferences prefs = getSharedPreferences(SHELL_PREFS, MODE_PRIVATE);
+ return RemoteBackendUrl.normalize(prefs.getString(PREF_REMOTE_BACKEND_URL, null));
+ }
+
+ private String resolveBackendUrl() {
+ return RemoteBackendUrl.resolveEffectiveUrl(resolveStoredRemoteBackendUrl());
+ }
+
+ private boolean isRemoteBackendMode() {
+ return resolveStoredRemoteBackendUrl() != null;
+ }
+
+ private void persistRemoteBackendUrl(@Nullable String normalizedOrNull) {
+ SharedPreferences.Editor editor = getSharedPreferences(SHELL_PREFS, MODE_PRIVATE).edit();
+ if (normalizedOrNull == null || normalizedOrNull.isEmpty()) {
+ editor.remove(PREF_REMOTE_BACKEND_URL);
+ } else {
+ editor.putString(PREF_REMOTE_BACKEND_URL, normalizedOrNull);
+ }
+ editor.apply();
+ }
+
+ private void restartAppForBackendChange() {
+ Intent intent = getPackageManager().getLaunchIntentForPackage(getPackageName());
+ if (intent != null) {
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+ startActivity(intent);
+ }
+ finishAffinity();
+ android.os.Process.killProcess(android.os.Process.myPid());
+ }
+
+ private void offerSwitchToLocalBackend(String title, @Nullable String detail) {
+ final String message = StartupErrorReport.format(
+ title,
+ detail,
+ StartupErrorReport.collect(this)
+ );
+ runOnUiThread(() -> {
+ mainHandler.removeCallbacksAndMessages(null);
+ if (webView != null) {
+ webView.setVisibility(android.view.View.INVISIBLE);
+ }
+ if (loadingLogo != null) {
+ loadingLogo.setVisibility(android.view.View.GONE);
+ }
+ if (progressBar != null) {
+ progressBar.setVisibility(android.view.View.GONE);
+ }
+ if (loadingText != null) {
+ loadingText.setVisibility(android.view.View.GONE);
+ }
+ if (errorText != null) {
+ errorText.setText(message);
+ errorText.setVisibility(android.view.View.VISIBLE);
+ }
+ if (isFinishing()) {
+ return;
+ }
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.remote_backend_unreachable_title)
+ .setMessage(R.string.remote_backend_unreachable_message)
+ .setPositiveButton(R.string.remote_backend_use_local, (dialog, which) -> {
+ persistRemoteBackendUrl(null);
+ restartAppForBackendChange();
+ })
+ .setNegativeButton(R.string.remote_backend_keep_trying, (dialog, which) -> {
+ backendFailed = false;
+ startupPageLoaded = false;
+ startupRequestHadLoadError = false;
+ connectionAttempts = 0;
+ if (errorText != null) {
+ errorText.setVisibility(android.view.View.GONE);
+ }
+ webView.loadUrl(resolveBackendUrl());
+ scheduleConnectionRetry("Connecting to remote backend…");
+ })
+ .setCancelable(true)
+ .show();
+ });
}
private void handleIncomingIntent(Intent intent) {
@@ -831,14 +993,24 @@ public class MainActivity extends AppCompatActivity {
}
connectionAttempts += 1;
if (connectionAttempts > MAX_CONNECTION_ATTEMPTS) {
- showStartupError(
- "Failed to connect to local MeshChatX server after waiting for startup.",
- null
- );
+ if (isRemoteBackendMode()) {
+ backendFailed = true;
+ offerSwitchToLocalBackend(
+ "Failed to connect to remote MeshChatX backend after waiting for startup.",
+ resolveBackendUrl()
+ );
+ } else {
+ showStartupError(
+ "Failed to connect to local MeshChatX server after waiting for startup.",
+ null
+ );
+ }
return;
}
- webView.loadUrl(SERVER_URL);
- scheduleConnectionRetry("Still starting…");
+ webView.loadUrl(resolveBackendUrl());
+ scheduleConnectionRetry(
+ isRemoteBackendMode() ? "Still connecting to remote backend…" : "Still starting…"
+ );
}, retryDelayMs);
}
@@ -1345,6 +1517,54 @@ public class MainActivity extends AppCompatActivity {
activity.runOnUiThread(() -> activity.setClearClipboardOnBackgroundEnabled(enabled));
}
+ @JavascriptInterface
+ public String getRemoteBackendUrl() {
+ String stored = activity.resolveStoredRemoteBackendUrl();
+ return stored != null ? stored : "";
+ }
+
+ @JavascriptInterface
+ public String getEffectiveBackendUrl() {
+ return activity.resolveBackendUrl();
+ }
+
+ @JavascriptInterface
+ public boolean isRemoteBackend() {
+ return activity.isRemoteBackendMode();
+ }
+
+ /**
+ * Persist a remote backend URL and restart the app.
+ * Pass empty string to clear and use the on-device local backend.
+ * Returns ok, invalid, or unsupported.
+ */
+ @JavascriptInterface
+ public String setRemoteBackendUrlAndRestart(String url) {
+ final String normalized = RemoteBackendUrl.normalize(url);
+ if (url != null && !url.trim().isEmpty() && normalized == null) {
+ return "invalid";
+ }
+ String current = activity.resolveStoredRemoteBackendUrl();
+ boolean same =
+ (normalized == null && current == null)
+ || (normalized != null && normalized.equals(current));
+ if (same) {
+ return "unchanged";
+ }
+ activity.runOnUiThread(() -> {
+ activity.persistRemoteBackendUrl(normalized);
+ Toast.makeText(
+ activity,
+ normalized == null
+ ? R.string.remote_backend_switching_local
+ : R.string.remote_backend_switching_remote,
+ Toast.LENGTH_SHORT)
+ .show();
+ activity.restartAppForBackendChange();
+ });
+ return "ok";
+ }
+
@JavascriptInterface
public String getSidebandPluginsDefaultPath() {
try {
@@ -1431,9 +1651,31 @@ public class MainActivity extends AppCompatActivity {
}
@JavascriptInterface
- public void requestBluetoothPermissions() {
+ public String requestBluetoothPermissions() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
- return;
+ return "granted";
+ }
+ if (hasBluetoothPermissions()) {
+ return "granted";
+ }
+ final String[] needed = new String[] {
+ Manifest.permission.BLUETOOTH_CONNECT,
+ Manifest.permission.BLUETOOTH_SCAN,
+ };
+ boolean canPrompt = false;
+ for (String permission : needed) {
+ if (ContextCompat.checkSelfPermission(activity, permission)
+ != PackageManager.PERMISSION_GRANTED) {
+ if (ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)
+ || !activity.wasBluetoothPermissionDeniedPermanently(permission)) {
+ canPrompt = true;
+ break;
+ }
+ }
+ }
+ if (!canPrompt) {
+ activity.runOnUiThread(activity::openAppPermissionSettings);
+ return "settings";
}
activity.runOnUiThread(() -> {
List<String> missing = new ArrayList<>();
@@ -1442,12 +1684,16 @@ public class MainActivity extends AppCompatActivity {
if (missing.isEmpty()) {
return;
}
+ for (String permission : missing) {
+ activity.markBluetoothPermissionPrompted(permission);
+ }
ActivityCompat.requestPermissions(
activity,
missing.toArray(new String[0]),
RNODE_BLUETOOTH_PERMISSION_REQUEST_CODE
);
});
+ return "requested";
}
@JavascriptInterface
@@ -1470,57 +1716,51 @@ public class MainActivity extends AppCompatActivity {
}
@JavascriptInterface
- public void requestUsbPermissions() {
+ public String requestUsbPermissions() {
activity.runOnUiThread(() -> {
try {
- UsbManager manager =
- (UsbManager) activity.getSystemService(Context.USB_SERVICE);
- if (manager == null) {
- return;
- }
- Intent intent = new Intent("com.meshchatx.USB_PERMISSION");
- android.app.PendingIntent permissionIntent =
- android.app.PendingIntent.getBroadcast(
- activity,
- 0,
- intent,
- android.app.PendingIntent.FLAG_IMMUTABLE);
- for (android.hardware.usb.UsbDevice device :
- manager.getDeviceList().values()) {
- if (!manager.hasPermission(device)) {
- manager.requestPermission(device, permissionIntent);
- }
- }
+ activity.startActivity(
+ new Intent(activity, com.meshchatx.rnode.RNodeFlasherActivity.class)
+ );
} catch (Exception e) {
Toast.makeText(
activity,
- "USB permission request failed",
+ "Could not open native RNode flasher",
Toast.LENGTH_SHORT).show();
}
});
+ return "requested";
}
@JavascriptInterface
- public void openBluetoothSettings() {
+ public boolean hasNativeRNodeFlasher() {
+ return true;
+ }
+
+ @JavascriptInterface
+ public void openRNodeFlasher() {
activity.runOnUiThread(() -> {
try {
- activity.startActivity(new Intent(Settings.ACTION_BLUETOOTH_SETTINGS));
- } catch (ActivityNotFoundException ignored) {
- Toast.makeText(activity, "Bluetooth settings unavailable", Toast.LENGTH_SHORT).show();
+ activity.startActivity(
+ new Intent(activity, com.meshchatx.rnode.RNodeFlasherActivity.class)
+ );
+ } catch (Exception e) {
+ Toast.makeText(
+ activity,
+ "Could not open native RNode flasher",
+ Toast.LENGTH_SHORT).show();
}
});
}
+ @JavascriptInterface
+ public void openBluetoothSettings() {
+ activity.runOnUiThread(activity::openAppPermissionSettings);
+ }
+
@JavascriptInterface
public void openUsbSettings() {
- activity.runOnUiThread(() -> {
- try {
- activity.startActivity(new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
- Uri.parse("package:" + activity.getPackageName())));
- } catch (ActivityNotFoundException ignored) {
- Toast.makeText(activity, "USB settings unavailable", Toast.LENGTH_SHORT).show();
- }
- });
+ activity.runOnUiThread(activity::openAppPermissionSettings);
}
@JavascriptInterface
diff --git a/android/app/src/main/java/com/meshchatx/MeshChatApplication.java b/android/app/src/main/java/com/meshchatx/MeshChatApplication.java
index 9702a4bb..20b4a7a3 100644
--- a/android/app/src/main/java/com/meshchatx/MeshChatApplication.java
+++ b/android/app/src/main/java/com/meshchatx/MeshChatApplication.java
@@ -26,11 +26,37 @@ public class MeshChatApplication extends PyApplication {
@Override
public void onCreate() {
+ // Preload libcodec2.so into the process before Chaquopy imports pycodec2.
+ // pycodec2.so NEEDED libcodec2.so has no RPATH so dlopen must already
+ // resolve it (jniLibs or System.loadLibrary).
+ preloadCodec2NativeLibrary();
super.onCreate();
appContext = getApplicationContext();
createNotificationChannels();
}
+ private void preloadCodec2NativeLibrary() {
+ try {
+ System.loadLibrary("codec2");
+ } catch (UnsatisfiedLinkError e) {
+ android.util.Log.w(
+ "MeshChatX",
+ "System.loadLibrary(codec2) failed before Python start: " + e.getMessage()
+ );
+ }
+ try {
+ String nativeDir = getApplicationInfo().nativeLibraryDir;
+ if (nativeDir != null && !nativeDir.isEmpty()) {
+ android.system.Os.setenv("MESHCHAT_NATIVE_LIB_DIR", nativeDir, true);
+ }
+ } catch (Exception e) {
+ android.util.Log.w(
+ "MeshChatX",
+ "Could not set MESHCHAT_NATIVE_LIB_DIR: " + e.getMessage()
+ );
+ }
+ }
+
private void createNotificationChannels() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
diff --git a/android/app/src/main/java/com/meshchatx/RemoteBackendUrl.java b/android/app/src/main/java/com/meshchatx/RemoteBackendUrl.java
new file mode 100644
index 00000000..7a3cc94d
--- /dev/null
+++ b/android/app/src/main/java/com/meshchatx/RemoteBackendUrl.java
@@ -0,0 +1,163 @@
+// SPDX-License-Identifier: 0BSD
+package com.meshchatx;
+
+import androidx.annotation.Nullable;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Locale;
+
+/**
+ * Normalize and validate a MeshChatX remote backend origin for the Android shell.
+ *
+ * Empty or null means use the on-device local backend. Only http(s) origins are
+ * accepted so the WebView never loads javascript or file schemes as the app root.
+ */
+public final class RemoteBackendUrl {
+ public static final String LOCAL_BACKEND_URL = "https://127.0.0.1:8000";
+
+ private RemoteBackendUrl() {}
+
+ /**
+ * @return normalized origin without trailing slash, or null for local backend
+ */
+ @Nullable
+ public static String normalize(@Nullable String raw) {
+ if (raw == null) {
+ return null;
+ }
+ String trimmed = raw.trim();
+ if (trimmed.isEmpty()) {
+ return null;
+ }
+ URI uri;
+ try {
+ uri = new URI(trimmed);
+ } catch (URISyntaxException e) {
+ return null;
+ }
+ String scheme = uri.getScheme();
+ if (scheme == null) {
+ return null;
+ }
+ String schemeLower = scheme.toLowerCase(Locale.ROOT);
+ if (!"http".equals(schemeLower) && !"https".equals(schemeLower)) {
+ return null;
+ }
+ if (uri.getUserInfo() != null && !uri.getUserInfo().isEmpty()) {
+ return null;
+ }
+ String host = uri.getHost();
+ if (host == null || host.isEmpty()) {
+ return null;
+ }
+ int port = uri.getPort();
+ StringBuilder out = new StringBuilder();
+ out.append(schemeLower).append("://").append(host.toLowerCase(Locale.ROOT));
+ if (port != -1) {
+ out.append(':').append(port);
+ }
+ String path = uri.getRawPath();
+ if (path != null && !path.isEmpty() && !"/".equals(path)) {
+ while (path.endsWith("/") && path.length() > 1) {
+ path = path.substring(0, path.length() - 1);
+ }
+ out.append(path);
+ }
+ return out.toString();
+ }
+
+ public static boolean isValid(@Nullable String raw) {
+ if (raw == null) {
+ return true;
+ }
+ String trimmed = raw.trim();
+ if (trimmed.isEmpty()) {
+ return true;
+ }
+ return normalize(trimmed) != null;
+ }
+
+ public static String resolveEffectiveUrl(@Nullable String storedRemote) {
+ String normalized = normalize(storedRemote);
+ return normalized != null ? normalized : LOCAL_BACKEND_URL;
+ }
+
+ public static boolean isLocalBackendUrl(String url) {
+ if (url == null) {
+ return false;
+ }
+ String n = normalize(url);
+ return LOCAL_BACKEND_URL.equals(n);
+ }
+
+ public static boolean isLoopbackHost(@Nullable String host) {
+ if (host == null) {
+ return false;
+ }
+ String h = host.toLowerCase(Locale.ROOT);
+ return "127.0.0.1".equals(h)
+ || "localhost".equals(h)
+ || "[::1]".equals(h)
+ || "::1".equals(h);
+ }
+
+ /**
+ * True when url is the backend root or a same-origin path under it.
+ */
+ public static boolean matchesBackend(@Nullable String url, @Nullable String backendUrl) {
+ if (url == null || backendUrl == null) {
+ return false;
+ }
+ if ("about:blank".equalsIgnoreCase(url)) {
+ return false;
+ }
+ URI page;
+ URI backend;
+ try {
+ page = new URI(url);
+ backend = new URI(backendUrl);
+ } catch (URISyntaxException e) {
+ return false;
+ }
+ if (page.getScheme() == null || backend.getScheme() == null) {
+ return false;
+ }
+ if (!page.getScheme().equalsIgnoreCase(backend.getScheme())) {
+ return false;
+ }
+ if (page.getHost() == null || backend.getHost() == null) {
+ return false;
+ }
+ if (!page.getHost().equalsIgnoreCase(backend.getHost())) {
+ return false;
+ }
+ int pagePort = effectivePort(page);
+ int backendPort = effectivePort(backend);
+ if (pagePort != backendPort) {
+ return false;
+ }
+ String backendPath = backend.getRawPath();
+ if (backendPath == null || backendPath.isEmpty() || "/".equals(backendPath)) {
+ return true;
+ }
+ String pagePath = page.getRawPath();
+ if (pagePath == null) {
+ pagePath = "";
+ }
+ return pagePath.equals(backendPath)
+ || pagePath.startsWith(backendPath.endsWith("/") ? backendPath : backendPath + "/");
+ }
+
+ private static int effectivePort(URI uri) {
+ int port = uri.getPort();
+ if (port != -1) {
+ return port;
+ }
+ String scheme = uri.getScheme();
+ if (scheme != null && scheme.equalsIgnoreCase("https")) {
+ return 443;
+ }
+ return 80;
+ }
+}
diff --git a/android/app/src/main/java/com/meshchatx/rnode/Esp32SerialFlasher.java b/android/app/src/main/java/com/meshchatx/rnode/Esp32SerialFlasher.java
new file mode 100644
index 00000000..331bfa38
--- /dev/null
+++ b/android/app/src/main/java/com/meshchatx/rnode/Esp32SerialFlasher.java
@@ -0,0 +1,419 @@
+package com.meshchatx.rnode;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.security.MessageDigest;
+import java.util.Locale;
+import java.util.Map;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Minimal ESP32 ROM bootloader flasher over USB serial (Apache-2.0 protocol).
+ *
+ * Implements the subset used by RNode firmware installs: sync, SPI attach,
+ * baud change, flash begin/data/end, and hard reset via DTR/RTS.
+ */
+public final class Esp32SerialFlasher {
+ public interface Progress {
+ void onStatus(String message);
+
+ void onProgress(int percent);
+ }
+
+ public static final class Image {
+ public final int address;
+ public final byte[] data;
+
+ public Image(int address, byte[] data) {
+ this.address = address;
+ this.data = data;
+ }
+ }
+
+ private static final int DEFAULT_TIMEOUT_MS = 3000;
+ private static final int SYNC_TIMEOUT_MS = 100;
+ private static final byte[] MAGIC_SYNC =
+ new byte[] {
+ 0x07,
+ 0x07,
+ 0x12,
+ 0x20,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ 0x55,
+ };
+
+ private static final int OP_FLASH_BEGIN = 0x02;
+ private static final int OP_FLASH_DATA = 0x03;
+ private static final int OP_FLASH_END = 0x04;
+ private static final int OP_SYNC = 0x08;
+ private static final int OP_SPI_ATTACH = 0x0d;
+ private static final int OP_CHANGE_BAUDRATE = 0x0f;
+
+ private final UsbSerialHub hub;
+ private final Progress progress;
+ private int sequence = 0;
+
+ public Esp32SerialFlasher(UsbSerialHub hub, Progress progress) {
+ this.hub = hub;
+ this.progress = progress != null ? progress : new Progress() {
+ @Override
+ public void onStatus(String message) {
+ }
+
+ @Override
+ public void onProgress(int percent) {
+ }
+ };
+ }
+
+ public void flash(List<Image> images, int flashBaud) throws IOException, InterruptedException {
+ if (images == null || images.isEmpty()) {
+ throw new IOException("No firmware images to flash");
+ }
+ progress.onStatus("Entering bootloader…");
+ enterBootloader();
+ progress.onStatus("Syncing…");
+ sync();
+ progress.onStatus("Attaching SPI flash…");
+ spiAttach();
+ if (flashBaud > 115200) {
+ progress.onStatus("Raising baud to " + flashBaud + "…");
+ changeBaud(flashBaud);
+ }
+
+ long totalBytes = 0;
+ for (Image image : images) {
+ totalBytes += image.data.length;
+ }
+ long written = 0;
+
+ for (int i = 0; i < images.size(); i++) {
+ Image image = images.get(i);
+ progress.onStatus(
+ String.format(
+ Locale.US,
+ "Flashing file %d/%d at 0x%X (%d bytes)…",
+ i + 1,
+ images.size(),
+ image.address,
+ image.data.length
+ )
+ );
+ flashImage(image, written, totalBytes);
+ written += image.data.length;
+ progress.onProgress((int) Math.min(100, (written * 100) / Math.max(1, totalBytes)));
+ }
+
+ progress.onStatus("Finishing…");
+ command(OP_FLASH_END, intLe(1), 0, DEFAULT_TIMEOUT_MS);
+ progress.onStatus("Resetting device…");
+ hardReset();
+ progress.onProgress(100);
+ progress.onStatus("Flash complete");
+ }
+
+ private void enterBootloader() throws IOException, InterruptedException {
+ // Classic ESP auto-reset: RTS=EN, DTR=IO0
+ hub.setDtrRts(false, false);
+ Thread.sleep(100);
+ hub.setDtrRts(true, false);
+ Thread.sleep(100);
+ hub.setDtrRts(false, true);
+ Thread.sleep(50);
+ hub.setDtrRts(false, false);
+ Thread.sleep(400);
+ }
+
+ private void hardReset() throws IOException, InterruptedException {
+ hub.setDtrRts(false, true);
+ Thread.sleep(100);
+ hub.setDtrRts(false, false);
+ Thread.sleep(200);
+ }
+
+ private void sync() throws IOException, InterruptedException {
+ IOException last = null;
+ for (int attempt = 0; attempt < 10; attempt++) {
+ try {
+ flushInput();
+ command(OP_SYNC, MAGIC_SYNC, 0, SYNC_TIMEOUT_MS);
+ // Drain extra sync responses
+ for (int i = 0; i < 7; i++) {
+ try {
+ readResponse(OP_SYNC, SYNC_TIMEOUT_MS);
+ } catch (IOException ignored) {
+ break;
+ }
+ }
+ return;
+ } catch (IOException e) {
+ last = e;
+ enterBootloader();
+ }
+ }
+ throw last != null ? last : new IOException("Failed to sync with ESP bootloader");
+ }
+
+ private void spiAttach() throws IOException {
+ command(OP_SPI_ATTACH, intLe(0), 0, DEFAULT_TIMEOUT_MS);
+ }
+
+ private void changeBaud(int baud) throws IOException, InterruptedException {
+ ByteBuffer buf = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN);
+ buf.putInt(baud);
+ buf.putInt(0);
+ command(OP_CHANGE_BAUDRATE, buf.array(), 0, DEFAULT_TIMEOUT_MS);
+ Thread.sleep(100);
+ hub.setBaudRate(baud);
+ Thread.sleep(50);
+ flushInput();
+ }
+
+ private void flashImage(Image image, long alreadyWritten, long totalBytes)
+ throws IOException {
+ final int blockSize = 0x400;
+ int numBlocks = (image.data.length + blockSize - 1) / blockSize;
+ ByteBuffer begin = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN);
+ begin.putInt(image.data.length);
+ begin.putInt(numBlocks);
+ begin.putInt(blockSize);
+ begin.putInt(image.address);
+ command(OP_FLASH_BEGIN, begin.array(), 0, DEFAULT_TIMEOUT_MS);
+
+ int seq = 0;
+ int offset = 0;
+ while (offset < image.data.length) {
+ int chunkLen = Math.min(blockSize, image.data.length - offset);
+ byte[] block = Arrays.copyOfRange(image.data, offset, offset + chunkLen);
+ if (block.length < blockSize) {
+ block = Arrays.copyOf(block, blockSize);
+ }
+ ByteBuffer data = ByteBuffer.allocate(16 + block.length).order(ByteOrder.LITTLE_ENDIAN);
+ data.putInt(block.length);
+ data.putInt(seq);
+ data.putInt(0);
+ data.putInt(0);
+ data.put(block);
+ int checksum = 0xef;
+ for (byte b : block) {
+ checksum ^= (b & 0xff);
+ }
+ command(OP_FLASH_DATA, data.array(), checksum, DEFAULT_TIMEOUT_MS);
+ seq++;
+ offset += chunkLen;
+ long approx = alreadyWritten + offset;
+ progress.onProgress((int) Math.min(99, (approx * 100) / Math.max(1, totalBytes)));
+ }
+ }
+
+ private byte[] command(int op, byte[] data, int checksum, int timeoutMs) throws IOException {
+ sequence = (sequence + 1) & 0xff;
+ ByteBuffer packet = ByteBuffer.allocate(8 + data.length).order(ByteOrder.LITTLE_ENDIAN);
+ packet.put((byte) 0x00);
+ packet.put((byte) op);
+ packet.putShort((short) data.length);
+ packet.putInt(checksum);
+ packet.put(data);
+ hub.write(slipEncode(packet.array()));
+ return readResponse(op, timeoutMs);
+ }
+
+ private byte[] readResponse(int expectedOp, int timeoutMs) throws IOException {
+ long deadline = System.currentTimeMillis() + timeoutMs;
+ ByteArrayOutputStream frame = new ByteArrayOutputStream();
+ boolean inFrame = false;
+ byte[] buf = new byte[256];
+ while (System.currentTimeMillis() < deadline) {
+ int n = hub.read(buf, 50);
+ if (n <= 0) {
+ continue;
+ }
+ for (int i = 0; i < n; i++) {
+ int b = buf[i] & 0xff;
+ if (!inFrame) {
+ if (b == 0xc0) {
+ inFrame = true;
+ frame.reset();
+ }
+ continue;
+ }
+ if (b == 0xc0) {
+ byte[] decoded = slipDecode(frame.toByteArray());
+ if (decoded.length < 8) {
+ inFrame = false;
+ continue;
+ }
+ int direction = decoded[0] & 0xff;
+ int op = decoded[1] & 0xff;
+ if (direction != 0x01 || op != expectedOp) {
+ inFrame = false;
+ continue;
+ }
+ int size =
+ (decoded[2] & 0xff) | ((decoded[3] & 0xff) << 8);
+ if (decoded.length < 8 + size) {
+ inFrame = false;
+ continue;
+ }
+ // status bytes at end of value for ROM protocol
+ if (size >= 2) {
+ int status = decoded[8 + size - 2] & 0xff;
+ int error = decoded[8 + size - 1] & 0xff;
+ if (status != 0) {
+ throw new IOException(
+ "ESP bootloader error op=0x"
+ + Integer.toHexString(expectedOp)
+ + " status="
+ + status
+ + " err="
+ + error
+ );
+ }
+ }
+ return Arrays.copyOfRange(decoded, 8, 8 + size);
+ }
+ frame.write(b);
+ }
+ }
+ throw new IOException("Timeout waiting for ESP response op=0x" + Integer.toHexString(expectedOp));
+ }
+
+ private void flushInput() throws IOException {
+ byte[] buf = new byte[512];
+ for (int i = 0; i < 5; i++) {
+ int n = hub.read(buf, 20);
+ if (n <= 0) {
+ break;
+ }
+ }
+ }
+
+ private static byte[] slipEncode(byte[] data) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream(data.length + 16);
+ out.write(0xc0);
+ for (byte value : data) {
+ int b = value & 0xff;
+ if (b == 0xc0) {
+ out.write(0xdb);
+ out.write(0xdc);
+ } else if (b == 0xdb) {
+ out.write(0xdb);
+ out.write(0xdd);
+ } else {
+ out.write(b);
+ }
+ }
+ out.write(0xc0);
+ return out.toByteArray();
+ }
+
+ private static byte[] slipDecode(byte[] data) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream(data.length);
+ for (int i = 0; i < data.length; i++) {
+ int b = data[i] & 0xff;
+ if (b == 0xdb && i + 1 < data.length) {
+ int n = data[++i] & 0xff;
+ if (n == 0xdc) {
+ out.write(0xc0);
+ } else if (n == 0xdd) {
+ out.write(0xdb);
+ }
+ } else {
+ out.write(b);
+ }
+ }
+ return out.toByteArray();
+ }
+
+ private static byte[] intLe(int value) {
+ return ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(value).array();
+ }
+
+ public static List<Image> imagesFromZip(
+ byte[] zipBytes,
+ Map<String, String> addressToFilename
+ ) throws IOException {
+ List<Image> images = new ArrayList<>();
+ try (java.util.zip.ZipInputStream zis =
+ new java.util.zip.ZipInputStream(new java.io.ByteArrayInputStream(zipBytes))) {
+ java.util.zip.ZipEntry entry;
+ java.util.Map<String, byte[]> files = new java.util.HashMap<>();
+ byte[] buf = new byte[8192];
+ while ((entry = zis.getNextEntry()) != null) {
+ if (entry.isDirectory()) {
+ continue;
+ }
+ String name = entry.getName();
+ int slash = name.lastIndexOf('/');
+ if (slash >= 0) {
+ name = name.substring(slash + 1);
+ }
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ int n;
+ while ((n = zis.read(buf)) > 0) {
+ bos.write(buf, 0, n);
+ }
+ files.put(name, bos.toByteArray());
+ }
+ for (Map.Entry<String, String> mapping : addressToFilename.entrySet()) {
+ int address = Integer.decode(mapping.getKey());
+ byte[] data = files.get(mapping.getValue());
+ if (data == null) {
+ throw new IOException("Missing firmware file in zip: " + mapping.getValue());
+ }
+ images.add(new Image(address, data));
+ }
+ }
+ // Flash lower addresses first
+ images.sort((a, b) -> Integer.compare(a.address, b.address));
+ return images;
+ }
+
+ public static String md5Hex(byte[] data) {
+ try {
+ MessageDigest md = MessageDigest.getInstance("MD5");
+ byte[] dig = md.digest(data);
+ StringBuilder sb = new StringBuilder(dig.length * 2);
+ for (byte b : dig) {
+ sb.append(String.format(Locale.US, "%02x", b));
+ }
+ return sb.toString();
+ } catch (Exception e) {
+ return "";
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/meshchatx/rnode/ProductCatalog.java b/android/app/src/main/java/com/meshchatx/rnode/ProductCatalog.java
new file mode 100644
index 00000000..6275edff
--- /dev/null
+++ b/android/app/src/main/java/com/meshchatx/rnode/ProductCatalog.java
@@ -0,0 +1,189 @@
+package com.meshchatx.rnode;
+
+import android.content.Context;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.io.BufferedReader;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/** Loads RNode ESP flash product catalog from assets/rnode_products.json. */
+public final class ProductCatalog {
+ public static final class Model {
+ public final int id;
+ public final String name;
+ public final Integer mappedId;
+ public final Map<String, String> flashFiles;
+ public final String flashSize;
+ public final String firmwareFilename;
+
+ Model(
+ int id,
+ String name,
+ Integer mappedId,
+ Map<String, String> flashFiles,
+ String flashSize,
+ String firmwareFilename
+ ) {
+ this.id = id;
+ this.name = name;
+ this.mappedId = mappedId;
+ this.flashFiles = flashFiles;
+ this.flashSize = flashSize;
+ this.firmwareFilename = firmwareFilename;
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+ }
+
+ public static final class Product {
+ public final String name;
+ public final int id;
+ public final int platform;
+ public final String firmwareFilename;
+ public final Map<String, String> flashFiles;
+ public final String flashSize;
+ public final List<Model> models;
+
+ Product(
+ String name,
+ int id,
+ int platform,
+ String firmwareFilename,
+ Map<String, String> flashFiles,
+ String flashSize,
+ List<Model> models
+ ) {
+ this.name = name;
+ this.id = id;
+ this.platform = platform;
+ this.firmwareFilename = firmwareFilename;
+ this.flashFiles = flashFiles;
+ this.flashSize = flashSize;
+ this.models = models;
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+
+ public Map<String, String> resolveFlashFiles(Model model) {
+ if (model != null && model.flashFiles != null && !model.flashFiles.isEmpty()) {
+ return model.flashFiles;
+ }
+ return flashFiles;
+ }
+
+ public String resolveFirmwareFilename(Model model) {
+ if (model != null && model.firmwareFilename != null && !model.firmwareFilename.isEmpty()) {
+ return model.firmwareFilename;
+ }
+ return firmwareFilename;
+ }
+ }
+
+ private final List<Product> products;
+
+ private ProductCatalog(List<Product> products) {
+ this.products = products;
+ }
+
+ public static ProductCatalog empty() {
+ return new ProductCatalog(new ArrayList<>());
+ }
+
+ public List<Product> products() {
+ return products;
+ }
+
+ public static ProductCatalog load(Context context) throws Exception {
+ String json;
+ try (InputStream in = context.getAssets().open("rnode_products.json");
+ BufferedReader reader =
+ new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
+ StringBuilder sb = new StringBuilder();
+ String line;
+ while ((line = reader.readLine()) != null) {
+ sb.append(line);
+ }
+ json = sb.toString();
+ }
+ JSONArray arr = new JSONArray(json);
+ List<Product> products = new ArrayList<>();
+ for (int i = 0; i < arr.length(); i++) {
+ JSONObject obj = arr.getJSONObject(i);
+ Map<String, String> flashFiles = readFlashFiles(obj.optJSONObject("flash_config"));
+ String flashSize = readFlashSize(obj.optJSONObject("flash_config"));
+ List<Model> models = new ArrayList<>();
+ JSONArray modelsArr = obj.optJSONArray("models");
+ if (modelsArr != null) {
+ for (int j = 0; j < modelsArr.length(); j++) {
+ JSONObject m = modelsArr.getJSONObject(j);
+ Integer mapped = m.has("mapped_id") && !m.isNull("mapped_id")
+ ? m.getInt("mapped_id")
+ : null;
+ Map<String, String> modelFlash = readFlashFiles(m.optJSONObject("flash_config"));
+ String modelSize = readFlashSize(m.optJSONObject("flash_config"));
+ models.add(
+ new Model(
+ m.getInt("id"),
+ m.getString("name"),
+ mapped,
+ modelFlash,
+ modelSize,
+ m.optString("firmware_filename", null)
+ )
+ );
+ }
+ }
+ products.add(
+ new Product(
+ obj.getString("name"),
+ obj.getInt("id"),
+ obj.getInt("platform"),
+ obj.optString("firmware_filename", ""),
+ flashFiles,
+ flashSize,
+ models
+ )
+ );
+ }
+ return new ProductCatalog(products);
+ }
+
+ private static Map<String, String> readFlashFiles(JSONObject flashConfig) {
+ Map<String, String> map = new LinkedHashMap<>();
+ if (flashConfig == null) {
+ return map;
+ }
+ JSONObject files = flashConfig.optJSONObject("flash_files");
+ if (files == null) {
+ return map;
+ }
+ Iterator<String> keys = files.keys();
+ while (keys.hasNext()) {
+ String key = keys.next();
+ map.put(key, files.optString(key, ""));
+ }
+ return map;
+ }
+
+ private static String readFlashSize(JSONObject flashConfig) {
+ if (flashConfig == null) {
+ return "4MB";
+ }
+ return flashConfig.optString("flash_size", "4MB");
+ }
+}
diff --git a/android/app/src/main/java/com/meshchatx/rnode/RNodeFlasherActivity.java b/android/app/src/main/java/com/meshchatx/rnode/RNodeFlasherActivity.java
new file mode 100644
index 00000000..eaac336e
--- /dev/null
+++ b/android/app/src/main/java/com/meshchatx/rnode/RNodeFlasherActivity.java
@@ -0,0 +1,463 @@
+package com.meshchatx.rnode;
+
+import android.Manifest;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.provider.Settings;
+import android.view.View;
+import android.webkit.CookieManager;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.ProgressBar;
+import android.widget.Spinner;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.app.ActivityCompat;
+import androidx.core.content.ContextCompat;
+
+import com.meshchatx.LocalhostTrustOkHttpClient;
+import com.meshchatx.R;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+
+/**
+ * Fully native RNode flasher (USB serial + ESP32 ROM protocol).
+ * Launched from the WebView Tools page via MeshChatXAndroid.openRNodeFlasher().
+ */
+public final class RNodeFlasherActivity extends AppCompatActivity implements UsbSerialHub.Listener {
+ private static final int REQ_BT = 4401;
+ private static final String LOCAL_API = "https://127.0.0.1:8000";
+
+ private final Handler mainHandler = new Handler(Looper.getMainLooper());
+ private final ExecutorService io = Executors.newSingleThreadExecutor();
+
+ private UsbSerialHub usbHub;
+ private ProductCatalog catalog;
+ @Nullable
+ private byte[] firmwareBytes;
+ @Nullable
+ private String firmwareName;
+
+ private Spinner usbSpinner;
+ private Spinner productSpinner;
+ private Spinner modelSpinner;
+ private ProgressBar progressBar;
+ private TextView statusView;
+ private TextView logView;
+ private Button flashButton;
+ private Button downloadButton;
+
+ private final List<UsbSerialHub.PortInfo> ports = new ArrayList<>();
+ private ArrayAdapter<UsbSerialHub.PortInfo> usbAdapter;
+ private ArrayAdapter<ProductCatalog.Product> productAdapter;
+ private ArrayAdapter<ProductCatalog.Model> modelAdapter;
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_rnode_flasher);
+ if (getSupportActionBar() != null) {
+ getSupportActionBar().setTitle(R.string.rnode_flasher_title);
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ }
+
+ usbSpinner = findViewById(R.id.rnodeUsbSpinner);
+ productSpinner = findViewById(R.id.rnodeProductSpinner);
+ modelSpinner = findViewById(R.id.rnodeModelSpinner);
+ progressBar = findViewById(R.id.rnodeProgress);
+ statusView = findViewById(R.id.rnodeStatus);
+ logView = findViewById(R.id.rnodeLog);
+ flashButton = findViewById(R.id.rnodeFlash);
+ downloadButton = findViewById(R.id.rnodeDownloadFirmware);
+
+ usbAdapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, ports);
+ usbSpinner.setAdapter(usbAdapter);
+
+ try {
+ catalog = ProductCatalog.load(this);
+ } catch (Exception e) {
+ appendLog("Failed to load product catalog: " + e.getMessage());
+ catalog = ProductCatalog.empty();
+ }
+ productAdapter =
+ new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, catalog.products());
+ productSpinner.setAdapter(productAdapter);
+ modelAdapter =
+ new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, new ArrayList<>());
+ modelSpinner.setAdapter(modelAdapter);
+
+ productSpinner.setOnItemSelectedListener(
+ new AdapterView.OnItemSelectedListener() {
+ @Override
+ public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
+ refreshModels();
+ }
+
+ @Override
+ public void onNothingSelected(AdapterView<?> parent) {
+ }
+ }
+ );
+
+ findViewById(R.id.rnodeRefreshUsb).setOnClickListener(v -> refreshPorts());
+ findViewById(R.id.rnodeRequestUsb).setOnClickListener(v -> requestUsbPermission());
+ findViewById(R.id.rnodeRequestBluetooth).setOnClickListener(v -> requestBluetooth());
+ findViewById(R.id.rnodeOpenAppSettings).setOnClickListener(v -> openAppSettings());
+ downloadButton.setOnClickListener(v -> downloadFirmware());
+ flashButton.setOnClickListener(v -> flashSelected());
+
+ usbHub = new UsbSerialHub(this);
+ usbHub.addListener(this);
+ usbHub.start();
+ refreshPorts();
+ refreshModels();
+ appendLog("Native RNode flasher ready.");
+ }
+
+ @Override
+ public boolean onSupportNavigateUp() {
+ finish();
+ return true;
+ }
+
+ @Override
+ protected void onDestroy() {
+ if (usbHub != null) {
+ usbHub.stop();
+ usbHub = null;
+ }
+ io.shutdownNow();
+ super.onDestroy();
+ }
+
+ @Override
+ public void onUsbPermissionResult(String deviceId, boolean granted) {
+ appendLog("USB permission " + (granted ? "granted" : "denied") + " for " + deviceId);
+ refreshPorts();
+ }
+
+ @Override
+ public void onPortsChanged() {
+ refreshPorts();
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ if (requestCode != REQ_BT) {
+ return;
+ }
+ boolean ok = true;
+ if (grantResults == null || grantResults.length == 0) {
+ ok = false;
+ } else {
+ for (int result : grantResults) {
+ if (result != PackageManager.PERMISSION_GRANTED) {
+ ok = false;
+ break;
+ }
+ }
+ }
+ appendLog(ok ? "Bluetooth permission granted." : "Bluetooth permission denied.");
+ Toast.makeText(
+ this,
+ ok ? "Bluetooth allowed" : "Bluetooth denied",
+ Toast.LENGTH_SHORT
+ ).show();
+ }
+
+ private void refreshPorts() {
+ ports.clear();
+ if (usbHub != null) {
+ ports.addAll(usbHub.listPorts());
+ }
+ usbAdapter.notifyDataSetChanged();
+ if (ports.isEmpty()) {
+ setStatus(getString(R.string.rnode_flasher_no_usb));
+ } else {
+ setStatus(ports.size() + " USB serial device(s)");
+ }
+ }
+
+ private void refreshModels() {
+ modelAdapter.clear();
+ ProductCatalog.Product product = selectedProduct();
+ if (product != null) {
+ modelAdapter.addAll(product.models);
+ }
+ modelAdapter.notifyDataSetChanged();
+ }
+
+ private void requestUsbPermission() {
+ UsbSerialHub.PortInfo port = selectedPort();
+ if (port == null) {
+ boolean any = usbHub != null && usbHub.requestPermissionsForAll();
+ appendLog(any ? "Requested USB permissions." : "No USB devices need permission.");
+ return;
+ }
+ if (port.hasPermission) {
+ appendLog("USB already allowed for " + port);
+ return;
+ }
+ if (usbHub != null) {
+ usbHub.requestPermission(port.deviceId);
+ appendLog("Requested USB permission for " + port);
+ }
+ }
+
+ private void requestBluetooth() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+ appendLog("Bluetooth runtime permission not required on this Android version.");
+ return;
+ }
+ List<String> missing = new ArrayList<>();
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT)
+ != PackageManager.PERMISSION_GRANTED) {
+ missing.add(Manifest.permission.BLUETOOTH_CONNECT);
+ }
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN)
+ != PackageManager.PERMISSION_GRANTED) {
+ missing.add(Manifest.permission.BLUETOOTH_SCAN);
+ }
+ if (missing.isEmpty()) {
+ appendLog("Bluetooth permission already granted.");
+ Toast.makeText(this, "Bluetooth already allowed", Toast.LENGTH_SHORT).show();
+ return;
+ }
+ ActivityCompat.requestPermissions(this, missing.toArray(new String[0]), REQ_BT);
+ appendLog("Requesting Bluetooth permissions…");
+ }
+
+ private void openAppSettings() {
+ startActivity(
+ new Intent(
+ Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
+ Uri.parse("package:" + getPackageName())
+ )
+ );
+ }
+
+ private void downloadFirmware() {
+ ProductCatalog.Product product = selectedProduct();
+ ProductCatalog.Model model = selectedModel();
+ if (product == null) {
+ Toast.makeText(this, "Select a product", Toast.LENGTH_SHORT).show();
+ return;
+ }
+ String filename = product.resolveFirmwareFilename(model);
+ if (filename == null || filename.isEmpty()) {
+ Toast.makeText(this, "No firmware filename for product", Toast.LENGTH_SHORT).show();
+ return;
+ }
+ setBusy(true);
+ setStatus("Downloading " + filename + "…");
+ appendLog("Downloading " + filename);
+ io.execute(() -> {
+ try {
+ byte[] bytes = fetchFirmwareZip(filename);
+ File cache = new File(getCacheDir(), filename);
+ try (FileOutputStream fos = new FileOutputStream(cache)) {
+ fos.write(bytes);
+ }
+ firmwareBytes = bytes;
+ firmwareName = filename;
+ mainHandler.post(() -> {
+ setBusy(false);
+ setStatus("Downloaded " + filename + " (" + bytes.length + " bytes)");
+ appendLog("Firmware ready: " + cache.getAbsolutePath());
+ });
+ } catch (Exception e) {
+ mainHandler.post(() -> {
+ setBusy(false);
+ setStatus("Download failed");
+ appendLog("Download error: " + e.getMessage());
+ Toast.makeText(this, e.getMessage(), Toast.LENGTH_LONG).show();
+ });
+ }
+ });
+ }
+
+ private void flashSelected() {
+ UsbSerialHub.PortInfo port = selectedPort();
+ ProductCatalog.Product product = selectedProduct();
+ ProductCatalog.Model model = selectedModel();
+ if (port == null) {
+ Toast.makeText(this, getString(R.string.rnode_flasher_no_usb), Toast.LENGTH_SHORT).show();
+ return;
+ }
+ if (product == null) {
+ Toast.makeText(this, "Select a product", Toast.LENGTH_SHORT).show();
+ return;
+ }
+ Map<String, String> flashFiles = product.resolveFlashFiles(model);
+ if (flashFiles == null || flashFiles.isEmpty()) {
+ Toast.makeText(this, "No ESP flash map for this product (nRF52 not in MVP)", Toast.LENGTH_LONG)
+ .show();
+ return;
+ }
+ if (firmwareBytes == null) {
+ Toast.makeText(this, "Download firmware first", Toast.LENGTH_SHORT).show();
+ return;
+ }
+ setBusy(true);
+ setStatus(getString(R.string.rnode_flasher_busy));
+ final byte[] zip = firmwareBytes;
+ final String deviceId = port.deviceId;
+ io.execute(() -> {
+ try {
+ List<Esp32SerialFlasher.Image> images =
+ Esp32SerialFlasher.imagesFromZip(zip, flashFiles);
+ usbHub.open(deviceId, 115200);
+ Esp32SerialFlasher flasher =
+ new Esp32SerialFlasher(
+ usbHub,
+ new Esp32SerialFlasher.Progress() {
+ @Override
+ public void onStatus(String message) {
+ mainHandler.post(() -> {
+ setStatus(message);
+ appendLog(message);
+ });
+ }
+
+ @Override
+ public void onProgress(int percent) {
+ mainHandler.post(() -> progressBar.setProgress(percent));
+ }
+ }
+ );
+ flasher.flash(images, 460800);
+ mainHandler.post(() -> {
+ setBusy(false);
+ setStatus("Flash complete");
+ Toast.makeText(this, "Flash complete", Toast.LENGTH_LONG).show();
+ });
+ } catch (Exception e) {
+ mainHandler.post(() -> {
+ setBusy(false);
+ setStatus("Flash failed");
+ appendLog("Flash error: " + e.getMessage());
+ Toast.makeText(this, e.getMessage(), Toast.LENGTH_LONG).show();
+ });
+ } finally {
+ if (usbHub != null) {
+ usbHub.close();
+ }
+ }
+ });
+ }
+
+ private byte[] fetchFirmwareZip(String filename) throws Exception {
+ OkHttpClient client = LocalhostTrustOkHttpClient.get();
+ // Resolve latest release asset URL via local API, then download through proxy.
+ String releaseUrl = LOCAL_API + "/api/v1/tools/rnode/latest_release";
+ Request releaseReq = authorizedGet(releaseUrl);
+ String assetUrl;
+ try (Response response = client.newCall(releaseReq).execute()) {
+ if (!response.isSuccessful() || response.body() == null) {
+ throw new IllegalStateException("latest_release HTTP " + response.code());
+ }
+ JSONObject release = new JSONObject(response.body().string());
+ JSONArray assets = release.optJSONArray("assets");
+ assetUrl = null;
+ if (assets != null) {
+ for (int i = 0; i < assets.length(); i++) {
+ JSONObject asset = assets.getJSONObject(i);
+ if (filename.equals(asset.optString("name"))) {
+ assetUrl = asset.optString("browser_download_url");
+ break;
+ }
+ }
+ }
+ if (assetUrl == null || assetUrl.isEmpty()) {
+ assetUrl =
+ "https://github.com/markqvist/RNode_Firmware/releases/latest/download/"
+ + filename;
+ }
+ }
+ String downloadUrl =
+ LOCAL_API
+ + "/api/v1/tools/rnode/download_firmware?url="
+ + Uri.encode(assetUrl);
+ Request dlReq = authorizedGet(downloadUrl);
+ try (Response response = client.newCall(dlReq).execute()) {
+ if (!response.isSuccessful() || response.body() == null) {
+ throw new IllegalStateException("download_firmware HTTP " + response.code());
+ }
+ ResponseBody body = response.body();
+ return body.bytes();
+ }
+ }
+
+ private Request authorizedGet(String url) {
+ Request.Builder builder = new Request.Builder().url(url).get();
+ String cookie = CookieManager.getInstance().getCookie(LOCAL_API);
+ if (cookie != null && !cookie.isEmpty()) {
+ builder.header("Cookie", cookie);
+ }
+ return builder.build();
+ }
+
+ @Nullable
+ private UsbSerialHub.PortInfo selectedPort() {
+ int idx = usbSpinner.getSelectedItemPosition();
+ if (idx < 0 || idx >= ports.size()) {
+ return null;
+ }
+ return ports.get(idx);
+ }
+
+ @Nullable
+ private ProductCatalog.Product selectedProduct() {
+ Object item = productSpinner.getSelectedItem();
+ return item instanceof ProductCatalog.Product ? (ProductCatalog.Product) item : null;
+ }
+
+ @Nullable
+ private ProductCatalog.Model selectedModel() {
+ Object item = modelSpinner.getSelectedItem();
+ return item instanceof ProductCatalog.Model ? (ProductCatalog.Model) item : null;
+ }
+
+ private void setBusy(boolean busy) {
+ flashButton.setEnabled(!busy);
+ downloadButton.setEnabled(!busy);
+ progressBar.setIndeterminate(busy && progressBar.getProgress() == 0);
+ }
+
+ private void setStatus(String text) {
+ statusView.setText(text);
+ }
+
+ private void appendLog(String line) {
+ CharSequence existing = logView.getText();
+ if (existing == null || existing.length() == 0) {
+ logView.setText(line);
+ } else {
+ logView.setText(existing + "\n" + line);
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/meshchatx/rnode/UsbSerialHub.java b/android/app/src/main/java/com/meshchatx/rnode/UsbSerialHub.java
new file mode 100644
index 00000000..89dbbd07
--- /dev/null
+++ b/android/app/src/main/java/com/meshchatx/rnode/UsbSerialHub.java
@@ -0,0 +1,330 @@
+package com.meshchatx.rnode;
+
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.hardware.usb.UsbDevice;
+import android.hardware.usb.UsbDeviceConnection;
+import android.hardware.usb.UsbManager;
+import android.os.Build;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+
+import com.hoho.android.usbserial.driver.UsbSerialDriver;
+import com.hoho.android.usbserial.driver.UsbSerialPort;
+import com.hoho.android.usbserial.driver.UsbSerialProber;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+/**
+ * Native USB serial hub for the RNode flasher Activity (no WebView).
+ */
+public final class UsbSerialHub {
+ private static final String TAG = "UsbSerialHub";
+ public static final String ACTION_USB_PERMISSION = "com.meshchatx.USB_PERMISSION";
+
+ public interface Listener {
+ void onUsbPermissionResult(String deviceId, boolean granted);
+
+ void onPortsChanged();
+ }
+
+ public static final class PortInfo {
+ public final String deviceId;
+ public final String deviceName;
+ public final int vendorId;
+ public final int productId;
+ public final boolean hasPermission;
+ public final String driverName;
+
+ PortInfo(
+ String deviceId,
+ String deviceName,
+ int vendorId,
+ int productId,
+ boolean hasPermission,
+ String driverName
+ ) {
+ this.deviceId = deviceId;
+ this.deviceName = deviceName;
+ this.vendorId = vendorId;
+ this.productId = productId;
+ this.hasPermission = hasPermission;
+ this.driverName = driverName;
+ }
+
+ @Override
+ public String toString() {
+ return deviceName
+ + " (0x"
+ + Integer.toHexString(vendorId)
+ + ":0x"
+ + Integer.toHexString(productId)
+ + ")";
+ }
+ }
+
+ private final Context appContext;
+ private final List<Listener> listeners = new CopyOnWriteArrayList<>();
+ @Nullable
+ private BroadcastReceiver permissionReceiver;
+ private boolean receiverRegistered = false;
+
+ @Nullable
+ private UsbSerialPort openPort;
+ @Nullable
+ private UsbDeviceConnection openConnection;
+ @Nullable
+ private String openDeviceId;
+
+ public UsbSerialHub(Context context) {
+ this.appContext = context.getApplicationContext();
+ }
+
+ public void addListener(Listener listener) {
+ if (listener != null) {
+ listeners.add(listener);
+ }
+ }
+
+ public void removeListener(Listener listener) {
+ listeners.remove(listener);
+ }
+
+ public void start() {
+ ensurePermissionReceiver();
+ }
+
+ public void stop() {
+ close();
+ if (receiverRegistered && permissionReceiver != null) {
+ try {
+ appContext.unregisterReceiver(permissionReceiver);
+ } catch (Exception ignored) {
+ }
+ receiverRegistered = false;
+ }
+ listeners.clear();
+ }
+
+ public List<PortInfo> listPorts() {
+ List<PortInfo> out = new ArrayList<>();
+ UsbManager manager = usbManager();
+ if (manager == null) {
+ return out;
+ }
+ List<UsbSerialDriver> drivers =
+ UsbSerialProber.getDefaultProber().findAllDrivers(manager);
+ for (UsbSerialDriver driver : drivers) {
+ UsbDevice device = driver.getDevice();
+ if (device == null) {
+ continue;
+ }
+ out.add(
+ new PortInfo(
+ deviceIdFor(device),
+ device.getDeviceName(),
+ device.getVendorId(),
+ device.getProductId(),
+ manager.hasPermission(device),
+ driver.getClass().getSimpleName()
+ )
+ );
+ }
+ return out;
+ }
+
+ public boolean requestPermission(String deviceId) {
+ UsbManager manager = usbManager();
+ if (manager == null) {
+ return false;
+ }
+ ensurePermissionReceiver();
+ UsbSerialDriver driver = findDriver(manager, deviceId);
+ if (driver == null) {
+ return false;
+ }
+ UsbDevice device = driver.getDevice();
+ if (manager.hasPermission(device)) {
+ return false;
+ }
+ Intent intent = new Intent(ACTION_USB_PERMISSION);
+ intent.setPackage(appContext.getPackageName());
+ int flags = PendingIntent.FLAG_UPDATE_CURRENT;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ flags |= PendingIntent.FLAG_IMMUTABLE;
+ }
+ PendingIntent pi =
+ PendingIntent.getBroadcast(appContext, device.getDeviceId(), intent, flags);
+ manager.requestPermission(device, pi);
+ return true;
+ }
+
+ public boolean requestPermissionsForAll() {
+ boolean any = false;
+ for (PortInfo port : listPorts()) {
+ if (!port.hasPermission) {
+ any |= requestPermission(port.deviceId);
+ }
+ }
+ return any;
+ }
+
+ public synchronized boolean open(String deviceId, int baudRate) throws IOException {
+ close();
+ UsbManager manager = usbManager();
+ if (manager == null) {
+ throw new IOException("USB service unavailable");
+ }
+ UsbSerialDriver driver = findDriver(manager, deviceId);
+ if (driver == null) {
+ throw new IOException("No serial driver for " + deviceId);
+ }
+ UsbDevice device = driver.getDevice();
+ if (!manager.hasPermission(device)) {
+ requestPermission(deviceId);
+ throw new IOException("USB permission required");
+ }
+ UsbDeviceConnection connection = manager.openDevice(device);
+ if (connection == null) {
+ throw new IOException("openDevice failed");
+ }
+ List<UsbSerialPort> ports = driver.getPorts();
+ if (ports == null || ports.isEmpty()) {
+ connection.close();
+ throw new IOException("No serial ports on device");
+ }
+ UsbSerialPort port = ports.get(0);
+ port.open(connection);
+ int baud = baudRate > 0 ? baudRate : 115200;
+ port.setParameters(baud, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE);
+ port.setDTR(false);
+ port.setRTS(false);
+ openPort = port;
+ openConnection = connection;
+ openDeviceId = deviceId;
+ return true;
+ }
+
+ public synchronized void close() {
+ if (openPort != null) {
+ try {
+ openPort.close();
+ } catch (Exception ignored) {
+ }
+ openPort = null;
+ }
+ if (openConnection != null) {
+ try {
+ openConnection.close();
+ } catch (Exception ignored) {
+ }
+ openConnection = null;
+ }
+ openDeviceId = null;
+ }
+
+ public synchronized boolean isOpen() {
+ return openPort != null;
+ }
+
+ @Nullable
+ public synchronized String getOpenDeviceId() {
+ return openDeviceId;
+ }
+
+ public synchronized void setBaudRate(int baudRate) throws IOException {
+ requireOpen();
+ openPort.setParameters(baudRate, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE);
+ }
+
+ public synchronized void setDtrRts(boolean dtr, boolean rts) throws IOException {
+ requireOpen();
+ openPort.setDTR(dtr);
+ openPort.setRTS(rts);
+ }
+
+ public synchronized void write(byte[] data) throws IOException {
+ requireOpen();
+ openPort.write(data, 2000);
+ }
+
+ public synchronized int read(byte[] buffer, int timeoutMs) throws IOException {
+ requireOpen();
+ return openPort.read(buffer, timeoutMs);
+ }
+
+ private void requireOpen() throws IOException {
+ if (openPort == null) {
+ throw new IOException("Serial port not open");
+ }
+ }
+
+ private void ensurePermissionReceiver() {
+ if (receiverRegistered) {
+ return;
+ }
+ permissionReceiver =
+ new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent == null || !ACTION_USB_PERMISSION.equals(intent.getAction())) {
+ return;
+ }
+ UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
+ boolean granted =
+ intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false);
+ String deviceId = device != null ? deviceIdFor(device) : "";
+ for (Listener listener : listeners) {
+ listener.onUsbPermissionResult(deviceId, granted);
+ listener.onPortsChanged();
+ }
+ }
+ };
+ IntentFilter filter = new IntentFilter(ACTION_USB_PERMISSION);
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ ContextCompat.registerReceiver(
+ appContext,
+ permissionReceiver,
+ filter,
+ ContextCompat.RECEIVER_NOT_EXPORTED
+ );
+ } else {
+ appContext.registerReceiver(permissionReceiver, filter);
+ }
+ receiverRegistered = true;
+ } catch (Exception e) {
+ Log.w(TAG, "registerReceiver failed: " + e.getMessage());
+ }
+ }
+
+ @Nullable
+ private UsbSerialDriver findDriver(UsbManager manager, String deviceId) {
+ List<UsbSerialDriver> drivers =
+ UsbSerialProber.getDefaultProber().findAllDrivers(manager);
+ for (UsbSerialDriver driver : drivers) {
+ UsbDevice device = driver.getDevice();
+ if (device != null && deviceIdFor(device).equals(deviceId)) {
+ return driver;
+ }
+ }
+ return null;
+ }
+
+ private static String deviceIdFor(UsbDevice device) {
+ return String.valueOf(device.getDeviceId());
+ }
+
+ @Nullable
+ private UsbManager usbManager() {
+ return (UsbManager) appContext.getSystemService(Context.USB_SERVICE);
+ }
+}
diff --git a/android/app/src/main/res/layout/activity_rnode_flasher.xml b/android/app/src/main/res/layout/activity_rnode_flasher.xml
new file mode 100644
index 00000000..885a4b31
--- /dev/null
+++ b/android/app/src/main/res/layout/activity_rnode_flasher.xml
@@ -0,0 +1,156 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/rnode_flasher_title"
+ android:textSize="22sp"
+ android:textStyle="bold"
+ android:paddingBottom="4dp" />
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/rnode_flasher_subtitle"
+ android:textSize="13sp"
+ android:paddingBottom="12dp" />
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/rnode_flasher_usb_device"
+ android:textStyle="bold" />
+
+ <Spinner
+ android:id="@+id/rnodeUsbSpinner"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="8dp" />
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:paddingBottom="8dp">
+
+ <Button
+ android:id="@+id/rnodeRefreshUsb"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="@string/rnode_flasher_refresh_usb" />
+
+ <Button
+ android:id="@+id/rnodeRequestUsb"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:layout_marginStart="8dp"
+ android:text="@string/rnode_flasher_allow_usb" />
+ </LinearLayout>
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/rnode_flasher_product"
+ android:textStyle="bold" />
+
+ <Spinner
+ android:id="@+id/rnodeProductSpinner"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="8dp" />
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/rnode_flasher_model"
+ android:textStyle="bold" />
+
+ <Spinner
+ android:id="@+id/rnodeModelSpinner"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="8dp" />
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:paddingBottom="8dp">
+
+ <Button
+ android:id="@+id/rnodeDownloadFirmware"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="@string/rnode_flasher_download" />
+
+ <Button
+ android:id="@+id/rnodeFlash"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:layout_marginStart="8dp"
+ android:text="@string/rnode_flasher_flash" />
+ </LinearLayout>
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:paddingBottom="8dp">
+
+ <Button
+ android:id="@+id/rnodeRequestBluetooth"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="@string/rnode_flasher_allow_bluetooth" />
+
+ <Button
+ android:id="@+id/rnodeOpenAppSettings"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:layout_marginStart="8dp"
+ android:text="@string/rnode_flasher_app_settings" />
+ </LinearLayout>
+
+ <ProgressBar
+ android:id="@+id/rnodeProgress"
+ style="?android:attr/progressBarStyleHorizontal"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:max="100"
+ android:progress="0" />
+
+ <TextView
+ android:id="@+id/rnodeStatus"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingTop="4dp"
+ android:paddingBottom="8dp"
+ android:text="@string/rnode_flasher_ready"
+ android:textSize="13sp" />
+
+ <ScrollView
+ android:layout_width="match_parent"
+ android:layout_height="0dp"
+ android:layout_weight="1">
+
+ <TextView
+ android:id="@+id/rnodeLog"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:fontFamily="monospace"
+ android:textSize="12sp"
+ android:textIsSelectable="true" />
+ </ScrollView>
+
+</LinearLayout>
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 8a96ef9f..b92850ef 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -25,5 +25,25 @@
<string name="share_apk_chooser_title">Share MeshChatX APK</string>
<string name="share_apk_failed">Could not prepare APK to share</string>
<string name="share_apk_unavailable">App package not found</string>
+ <string name="remote_backend_unreachable_title">Remote backend unreachable</string>
+ <string name="remote_backend_unreachable_message">Could not open the configured MeshChatX backend URL. Use the on-device backend, or keep trying if the desktop or server is still starting.</string>
+ <string name="remote_backend_use_local">Use local backend</string>
+ <string name="remote_backend_keep_trying">Keep trying</string>
+ <string name="remote_backend_switching_local">Switching to on-device backend…</string>
+ <string name="remote_backend_switching_remote">Switching to remote backend…</string>
+ <string name="rnode_flasher_title">RNode Flasher</string>
+ <string name="rnode_flasher_subtitle">Native USB flashing for ESP32 RNodes. No WebView serial.</string>
+ <string name="rnode_flasher_usb_device">USB serial device</string>
+ <string name="rnode_flasher_refresh_usb">Refresh</string>
+ <string name="rnode_flasher_allow_usb">Allow USB</string>
+ <string name="rnode_flasher_product">Product</string>
+ <string name="rnode_flasher_model">Model</string>
+ <string name="rnode_flasher_download">Download firmware</string>
+ <string name="rnode_flasher_flash">Flash</string>
+ <string name="rnode_flasher_allow_bluetooth">Allow Bluetooth</string>
+ <string name="rnode_flasher_app_settings">App settings</string>
+ <string name="rnode_flasher_ready">Ready</string>
+ <string name="rnode_flasher_no_usb">No USB serial devices found. Plug in your RNode.</string>
+ <string name="rnode_flasher_busy">Working…</string>
</resources>
diff --git a/android/app/src/main/res/xml/device_filter.xml b/android/app/src/main/res/xml/device_filter.xml
index ea67a38f..53a5f500 100644
--- a/android/app/src/main/res/xml/device_filter.xml
+++ b/android/app/src/main/res/xml/device_filter.xml
@@ -37,6 +37,9 @@
<usb-device vendor-id="11914" product-id="10" /> <!-- 0x2E8A / 0x000A: Raspberry Pi Pico SDK -->
<usb-device vendor-id="6790" product-id="21972" /><!-- 0x1A86 / 0x55D4: Qinheng CH9102F -->
+ <!-- Espressif / ESP32-S2/S3 USB-JTAG and CDC -->
+ <usb-device vendor-id="12346" /> <!-- 0x303A Espressif -->
+
<!-- Adafruit RAK4630 -->
<usb-device vendor-id="9114" product-id="32809" /><!-- 0x239a / 0x8029: RAK4630 -->
diff --git a/android/app/src/test/java/com/meshchatx/RemoteBackendUrlTest.java b/android/app/src/test/java/com/meshchatx/RemoteBackendUrlTest.java
new file mode 100644
index 00000000..573d2a09
--- /dev/null
+++ b/android/app/src/test/java/com/meshchatx/RemoteBackendUrlTest.java
@@ -0,0 +1,81 @@
+package com.meshchatx;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+public class RemoteBackendUrlTest {
+
+ @Test
+ public void normalize_emptyMeansLocal() {
+ Assert.assertNull(RemoteBackendUrl.normalize(null));
+ Assert.assertNull(RemoteBackendUrl.normalize(""));
+ Assert.assertNull(RemoteBackendUrl.normalize(" "));
+ }
+
+ @Test
+ public void normalize_acceptsHttpLanOrigin() {
+ Assert.assertEquals(
+ "http://192.168.1.10:9337",
+ RemoteBackendUrl.normalize("http://192.168.1.10:9337/")
+ );
+ Assert.assertEquals(
+ "https://mesh.example:8443/meshchatx",
+ RemoteBackendUrl.normalize("HTTPS://Mesh.Example:8443/meshchatx/")
+ );
+ }
+
+ @Test
+ public void normalize_rejectsUnsafeSchemesAndUserInfo() {
+ Assert.assertNull(RemoteBackendUrl.normalize("javascript:alert(1)"));
+ Assert.assertNull(RemoteBackendUrl.normalize("file:///sdcard/x"));
+ Assert.assertNull(RemoteBackendUrl.normalize("https://user:pass@192.168.1.10:9337"));
+ Assert.assertNull(RemoteBackendUrl.normalize("not a url"));
+ }
+
+ @Test
+ public void resolveEffectiveUrl_defaultsToLocal() {
+ Assert.assertEquals(
+ RemoteBackendUrl.LOCAL_BACKEND_URL,
+ RemoteBackendUrl.resolveEffectiveUrl(null)
+ );
+ Assert.assertEquals(
+ "http://10.0.0.2:8000",
+ RemoteBackendUrl.resolveEffectiveUrl("http://10.0.0.2:8000")
+ );
+ }
+
+ @Test
+ public void matchesBackend_requiresSameOrigin() {
+ Assert.assertTrue(
+ RemoteBackendUrl.matchesBackend(
+ "https://192.168.1.10:9337/",
+ "https://192.168.1.10:9337"
+ )
+ );
+ Assert.assertTrue(
+ RemoteBackendUrl.matchesBackend(
+ "https://192.168.1.10:9337/#/settings",
+ "https://192.168.1.10:9337"
+ )
+ );
+ Assert.assertFalse(
+ RemoteBackendUrl.matchesBackend(
+ "https://192.168.1.10.evil:9337/",
+ "https://192.168.1.10:9337"
+ )
+ );
+ Assert.assertFalse(
+ RemoteBackendUrl.matchesBackend(
+ "https://example.com/",
+ "https://192.168.1.10:9337"
+ )
+ );
+ }
+
+ @Test
+ public void isLoopbackHost_detectsLocalHosts() {
+ Assert.assertTrue(RemoteBackendUrl.isLoopbackHost("127.0.0.1"));
+ Assert.assertTrue(RemoteBackendUrl.isLoopbackHost("localhost"));
+ Assert.assertFalse(RemoteBackendUrl.isLoopbackHost("192.168.1.10"));
+ }
+}
diff --git a/android/app/src/test/java/com/meshchatx/rnode/Esp32SerialFlasherTest.java b/android/app/src/test/java/com/meshchatx/rnode/Esp32SerialFlasherTest.java
new file mode 100644
index 00000000..6950c91d
--- /dev/null
+++ b/android/app/src/test/java/com/meshchatx/rnode/Esp32SerialFlasherTest.java
@@ -0,0 +1,47 @@
+package com.meshchatx.rnode;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+import java.io.ByteArrayOutputStream;
+
+public class Esp32SerialFlasherTest {
+ @Test
+ public void imagesFromZipMapsAddresses() throws Exception {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ try (ZipOutputStream zos = new ZipOutputStream(bos)) {
+ zos.putNextEntry(new ZipEntry("app.bin"));
+ zos.write(new byte[] {1, 2, 3, 4});
+ zos.closeEntry();
+ zos.putNextEntry(new ZipEntry("bootloader.bin"));
+ zos.write(new byte[] {9, 8});
+ zos.closeEntry();
+ }
+ Map<String, String> map = new LinkedHashMap<>();
+ map.put("0x1000", "bootloader.bin");
+ map.put("0x10000", "app.bin");
+ List<Esp32SerialFlasher.Image> images =
+ Esp32SerialFlasher.imagesFromZip(bos.toByteArray(), map);
+ assertEquals(2, images.size());
+ assertEquals(0x1000, images.get(0).address);
+ assertEquals(2, images.get(0).data.length);
+ assertEquals(0x10000, images.get(1).address);
+ assertEquals(4, images.get(1).data.length);
+ }
+
+ @Test
+ public void md5HexIsStable() {
+ String hex = Esp32SerialFlasher.md5Hex(new byte[] {1, 2, 3});
+ assertFalse(hex.isEmpty());
+ assertEquals(32, hex.length());
+ assertTrue(hex.matches("[0-9a-f]+"));
+ }
+}
diff --git a/docs/agents/skills/android-webview-bridge/SKILL.md b/docs/agents/skills/android-webview-bridge/SKILL.md
index 8025621d..a9b8f8c2 100644
--- a/docs/agents/skills/android-webview-bridge/SKILL.md
+++ b/docs/agents/skills/android-webview-bridge/SKILL.md
@@ -32,16 +32,23 @@ Keep Chaquopy backend boot, WebView file choosers, storage locks, and external n
- Chaquopy has no pyjnius. Ship `android/app/src/main/python/jnius/` as a shim over `java.jclass`.
- Override `usb4a` under `android/app/src/main/python/usb4a/` and inject the Activity via `meshchat_wrapper.start_server(..., activity)`.
- BLE uses bundled `able` plus `org.able.BLE` (not Kivy PythonActivity).
+- RNode flasher is native `RNodeFlasherActivity` (USB via `UsbSerialHub`, ESP32 ROM flash in Java). WebView only launches it via `openRNodeFlasher()`.
+- Codec2 requires `System.loadLibrary("codec2")` before Python import (see `MeshChatApplication`).
- Keep RNS panic containment and `panic_on_interface_error = No`.
## Key files
- `android/app/src/main/java/com/meshchatx/MainActivity.java`
+- `android/app/src/main/java/com/meshchatx/rnode/RNodeFlasherActivity.java`
+- `android/app/src/main/java/com/meshchatx/rnode/UsbSerialHub.java`
+- `android/app/src/main/java/com/meshchatx/rnode/Esp32SerialFlasher.java`
+- `android/app/src/main/java/com/meshchatx/MeshChatApplication.java`
- `android/app/src/main/java/org/able/BLE.java`
- `android/app/src/main/python/meshchat_wrapper.py`
- `android/app/src/main/python/jnius/`
- `android/app/src/main/python/usb4a/`
- `android/app/src/main/python/able/`
+- `meshchatx/android_codec2.py`
- `meshchatx/src/backend/android_rnode/`
- `meshchatx/src/frontend/js/rnode/AndroidBridge.js`
- `docs/agents/conventions/android.md`
diff --git a/meshchatx.rsm b/meshchatx.rsm
index 847b4487..a3898bb7 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ
diff --git a/meshchatx/android_codec2.py b/meshchatx/android_codec2.py
index 0d4baca2..3f97013c 100644
--- a/meshchatx/android_codec2.py
+++ b/meshchatx/android_codec2.py
@@ -74,6 +74,18 @@ def _libcodec2_candidates() -> list[Path]:
return candidates
+def _java_system_load_library(name: str = "codec2") -> bool:
+ """Ask the Android runtime to load a jniLibs shared object by soname."""
+ try:
+ from java.lang import System as JavaSystem
+
+ JavaSystem.loadLibrary(name)
+ return True
+ except Exception as exc:
+ logger.debug("Java System.loadLibrary(%s) failed: %s", name, exc)
+ return False
+
+
def ensure_codec2_native_library() -> bool:
"""Preload libcodec2.so so import pycodec2 works on Android.
@@ -81,6 +93,9 @@ def ensure_codec2_native_library() -> bool:
extension module only declares a NEEDED entry for libcodec2.so. Without
preloading or bundling the shared library next to pycodec2.so, imports
fail at runtime with dlopen errors.
+
+ Order: Java System.loadLibrary (jniLibs), bare CDLL name, then absolute
+ candidate paths including MESHCHAT_NATIVE_LIB_DIR.
"""
global _codec2_preload_done, _codec2_preload_error
@@ -92,6 +107,10 @@ def ensure_codec2_native_library() -> bool:
if not _is_chaquopy_android():
return True
+ if _java_system_load_library("codec2"):
+ logger.info("Loaded Codec2 via Java System.loadLibrary(codec2)")
+ return True
+
try:
_cdll_load("libcodec2.so")
return True
diff --git a/meshchatx/src/frontend/components/rnode/RNodeCapabilitiesBanner.vue b/meshchatx/src/frontend/components/rnode/RNodeCapabilitiesBanner.vue
index f612fab6..7c9fd0d0 100644
--- a/meshchatx/src/frontend/components/rnode/RNodeCapabilitiesBanner.vue
+++ b/meshchatx/src/frontend/components/rnode/RNodeCapabilitiesBanner.vue
@@ -92,6 +92,13 @@ export default {
labelKey: "tools.rnode_flasher.support.actions.load_polyfill",
});
}
+ if (this.androidAvailable) {
+ actions.push({
+ id: "open-native-flasher",
+ icon: "usb",
+ labelKey: "tools.rnode_flasher.support.actions.open_native",
+ });
+ }
return actions;
},
_bluetoothActions() {
diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index c7d06110..c8bb7ea2 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -729,6 +729,9 @@
v-if="isMeshChatXAndroid"
:visible="showSection('android')"
:android-shell-privacy="androidShellPrivacy"
+ :remote-backend-url="androidRemoteBackendUrl"
+ :effective-backend-url="androidEffectiveBackendUrl"
+ :remote-backend-active="androidRemoteBackendActive"
@update:block-screenshots="
(v) => {
androidShellPrivacy.blockScreenshots = v;
@@ -741,6 +744,9 @@
saveAndroidClearClipboardOnBackground();
}
"
+ @update:remote-backend-url="(v) => (androidRemoteBackendUrl = v)"
+ @apply-remote-backend="applyAndroidRemoteBackend"
+ @clear-remote-backend="clearAndroidRemoteBackend"
@share-apk="shareAndroidApk"
/>
@@ -3055,6 +3061,9 @@ export default {
blockScreenshots: false,
clearClipboardOnBackground: false,
},
+ androidRemoteBackendUrl: "",
+ androidEffectiveBackendUrl: "",
+ androidRemoteBackendActive: false,
reticulumInstance: {
share_instance: true,
local_hops_delta: false,
@@ -3746,6 +3755,41 @@ export default {
blockScreenshots: bridge.getBlockScreenshots(),
clearClipboardOnBackground: bridge.getClearClipboardOnBackground(),
};
+ this.androidRemoteBackendUrl = bridge.getRemoteBackendUrl() || "";
+ this.androidEffectiveBackendUrl = bridge.getEffectiveBackendUrl() || "";
+ this.androidRemoteBackendActive = bridge.isRemoteBackend() === true;
+ },
+ applyAndroidRemoteBackend() {
+ const bridge = new AndroidBridge();
+ const draft = (this.androidRemoteBackendUrl || "").trim();
+ const result = bridge.setRemoteBackendUrlAndRestart(draft);
+ if (result === "invalid") {
+ ToastUtils.error(this.$t("settings.android_remote_backend_invalid"));
+ return;
+ }
+ if (result === "unsupported") {
+ ToastUtils.error(this.$t("settings.android_privacy_save_failed"));
+ return;
+ }
+ if (result === "unchanged") {
+ ToastUtils.info(this.$t("settings.android_remote_backend_unchanged"));
+ return;
+ }
+ ToastUtils.success(this.$t("settings.android_remote_backend_restarting"));
+ },
+ clearAndroidRemoteBackend() {
+ this.androidRemoteBackendUrl = "";
+ const bridge = new AndroidBridge();
+ const result = bridge.setRemoteBackendUrlAndRestart("");
+ if (result === "unsupported") {
+ ToastUtils.error(this.$t("settings.android_privacy_save_failed"));
+ return;
+ }
+ if (result === "unchanged") {
+ ToastUtils.info(this.$t("settings.android_remote_backend_already_local"));
+ return;
+ }
+ ToastUtils.success(this.$t("settings.android_remote_backend_restarting"));
},
saveAndroidBlockScreenshots() {
const bridge = new AndroidBridge();
diff --git a/meshchatx/src/frontend/components/settings/sections/AndroidSettingsSection.vue b/meshchatx/src/frontend/components/settings/sections/AndroidSettingsSection.vue
index 4097675c..b0ff9148 100644
--- a/meshchatx/src/frontend/components/settings/sections/AndroidSettingsSection.vue
+++ b/meshchatx/src/frontend/components/settings/sections/AndroidSettingsSection.vue
@@ -38,6 +38,42 @@
}}</span>
</span>
</label>
+
+ <div class="space-y-2">
+ <div class="setting-toggle__title">{{ $t("settings.android_remote_backend_heading") }}</div>
+ <p class="text-xs opacity-80">{{ $t("settings.android_remote_backend_desc") }}</p>
+ <input
+ :value="remoteBackendUrl"
+ type="url"
+ inputmode="url"
+ autocomplete="off"
+ spellcheck="false"
+ class="w-full rounded-sm border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm"
+ :placeholder="$t('settings.android_remote_backend_placeholder')"
+ @input="$emit('update:remoteBackendUrl', $event.target.value)"
+ />
+ <p v-if="remoteBackendActive" class="text-xs text-emerald-700 dark:text-emerald-300">
+ {{ $t("settings.android_remote_backend_active", { url: effectiveBackendUrl }) }}
+ </p>
+ <div class="flex flex-wrap gap-2">
+ <button
+ type="button"
+ class="btn-maintenance border-blue-200 dark:border-blue-900/30 text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/10 hover:bg-blue-100 dark:hover:bg-blue-900/20"
+ @click="$emit('apply-remote-backend')"
+ >
+ {{ $t("settings.android_remote_backend_apply") }}
+ </button>
+ <button
+ type="button"
+ class="btn-maintenance border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-200 bg-gray-50 dark:bg-gray-900/40 hover:bg-gray-100 dark:hover:bg-gray-800"
+ :disabled="!remoteBackendActive && !(remoteBackendUrl || '').trim()"
+ @click="$emit('clear-remote-backend')"
+ >
+ {{ $t("settings.android_remote_backend_use_local") }}
+ </button>
+ </div>
+ </div>
+
<button
type="button"
class="btn-maintenance border-blue-200 dark:border-blue-900/30 text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/10 hover:bg-blue-100 dark:hover:bg-blue-900/20"
@@ -74,7 +110,26 @@ export default {
type: Object,
required: true,
},
+ remoteBackendUrl: {
+ type: String,
+ default: "",
+ },
+ effectiveBackendUrl: {
+ type: String,
+ default: "",
+ },
+ remoteBackendActive: {
+ type: Boolean,
+ default: false,
+ },
},
- emits: ["update:blockScreenshots", "update:clearClipboardOnBackground", "share-apk"],
+ emits: [
+ "update:blockScreenshots",
+ "update:clearClipboardOnBackground",
+ "update:remoteBackendUrl",
+ "apply-remote-backend",
+ "clear-remote-backend",
+ "share-apk",
+ ],
};
</script>
diff --git a/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue b/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue
index 013a98be..97cf0ecf 100644
--- a/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue
+++ b/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue
@@ -257,11 +257,35 @@ export default {
},
},
mounted() {
+ this._onAndroidPermission = (event) => {
+ const detail = event?.detail;
+ if (!detail) {
+ return;
+ }
+ this.refreshCapabilities();
+ if (detail.group === "bluetooth") {
+ if (detail.granted) {
+ ToastUtils.success(this.$t("tools.rnode_flasher.support.actions.bluetooth_granted"));
+ } else {
+ ToastUtils.warning(this.$t("tools.rnode_flasher.support.actions.bluetooth_denied"));
+ }
+ }
+ };
+ window.addEventListener("meshchatx-android-permission", this._onAndroidPermission);
+ if (this.androidBridge.hasNativeRNodeFlasher()) {
+ this.androidBridge.openRNodeFlasher();
+ ToastUtils.info(this.$t("tools.rnode_flasher.support.actions.opened_native"));
+ }
this.refreshCapabilities();
this.connectionMethod = pickDefaultTransport(this.capabilities);
this.loadVendorLibraries();
this.fetchLatestRelease();
},
+ beforeUnmount() {
+ if (this._onAndroidPermission) {
+ window.removeEventListener("meshchatx-android-permission", this._onAndroidPermission);
+ }
+ },
methods: {
refreshCapabilities() {
this.capabilities = detectCapabilities();
@@ -270,15 +294,30 @@ export default {
this.selectedProduct = product;
this.selectedModel = null;
},
- onCapabilitiesAction(action) {
+ async onCapabilitiesAction(action) {
if (action === "load-polyfill") {
this.loadVendorLibraries(true);
ToastUtils.info(this.$t("tools.rnode_flasher.support.actions.polyfill_loading"));
return;
}
+ if (action === "open-native-flasher" || action === "request-usb") {
+ if (this.androidBridge.openRNodeFlasher()) {
+ ToastUtils.info(this.$t("tools.rnode_flasher.support.actions.opened_native"));
+ }
+ return;
+ }
if (action === "request-bluetooth") {
- this.androidBridge.requestPermission(AndroidBridge.PERM_BLUETOOTH);
- ToastUtils.info(this.$t("tools.rnode_flasher.support.actions.bluetooth_requested"));
+ const status = await this.androidBridge.requestPermission(AndroidBridge.PERM_BLUETOOTH);
+ if (status === "granted") {
+ ToastUtils.success(this.$t("tools.rnode_flasher.support.actions.bluetooth_already_granted"));
+ } else if (status === "settings") {
+ ToastUtils.info(this.$t("tools.rnode_flasher.support.actions.bluetooth_open_settings"));
+ } else if (status === "requested") {
+ ToastUtils.info(this.$t("tools.rnode_flasher.support.actions.bluetooth_requested"));
+ } else {
+ ToastUtils.warning(this.$t("tools.rnode_flasher.support.actions.bluetooth_unsupported"));
+ }
+ this.refreshCapabilities();
return;
}
if (action === "open-bluetooth-settings") {
diff --git a/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
index fe3bdc31..2abb3e68 100644
--- a/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
+++ b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
@@ -148,12 +148,16 @@ export const CORE_SETTINGS_SECTION_KEYWORDS = {
"screenshot",
"clipboard",
"privacy",
+ "remote backend",
+ "backend URL",
"settings.android_privacy_heading",
"settings.android_privacy_desc",
"settings.android_block_screenshots",
"settings.android_block_screenshots_desc",
"settings.android_clear_clipboard_on_background",
"settings.android_clear_clipboard_on_background_desc",
+ "settings.android_remote_backend_heading",
+ "settings.android_remote_backend_desc",
"settings.share_apk_heading",
"settings.share_apk_desc",
"settings.share_apk",
diff --git a/meshchatx/src/frontend/js/remoteBackendUrl.js b/meshchatx/src/frontend/js/remoteBackendUrl.js
new file mode 100644
index 00000000..43423ee0
--- /dev/null
+++ b/meshchatx/src/frontend/js/remoteBackendUrl.js
@@ -0,0 +1,73 @@
+/**
+ * Normalize a MeshChatX remote backend URL for the Android shell.
+ * Empty string means the on-device local backend.
+ */
+
+const LOCAL_BACKEND_URL = "https://127.0.0.1:8000";
+
+/**
+ * @param {string | null | undefined} raw
+ * @returns {string | null} normalized origin without trailing slash, or null for local
+ */
+export function normalizeRemoteBackendUrl(raw) {
+ if (raw == null) {
+ return null;
+ }
+ const trimmed = String(raw).trim();
+ if (!trimmed) {
+ return null;
+ }
+ let url;
+ try {
+ url = new URL(trimmed);
+ } catch {
+ return null;
+ }
+ const scheme = url.protocol.replace(/:$/, "").toLowerCase();
+ if (scheme !== "http" && scheme !== "https") {
+ return null;
+ }
+ if (url.username || url.password) {
+ return null;
+ }
+ if (!url.hostname) {
+ return null;
+ }
+ let out = `${scheme}://${url.hostname.toLowerCase()}`;
+ if (url.port) {
+ out += `:${url.port}`;
+ }
+ let path = url.pathname || "";
+ if (path && path !== "/") {
+ while (path.length > 1 && path.endsWith("/")) {
+ path = path.slice(0, -1);
+ }
+ out += path;
+ }
+ return out;
+}
+
+/**
+ * @param {string | null | undefined} raw
+ * @returns {boolean}
+ */
+export function isValidRemoteBackendUrl(raw) {
+ if (raw == null) {
+ return true;
+ }
+ const trimmed = String(raw).trim();
+ if (!trimmed) {
+ return true;
+ }
+ return normalizeRemoteBackendUrl(trimmed) != null;
+}
+
+/**
+ * @param {string | null | undefined} storedRemote
+ * @returns {string}
+ */
+export function resolveEffectiveBackendUrl(storedRemote) {
+ return normalizeRemoteBackendUrl(storedRemote) || LOCAL_BACKEND_URL;
+}
+
+export { LOCAL_BACKEND_URL };
diff --git a/meshchatx/src/frontend/js/rnode/AndroidBridge.js b/meshchatx/src/frontend/js/rnode/AndroidBridge.js
index c8c46bc7..9769e683 100644
--- a/meshchatx/src/frontend/js/rnode/AndroidBridge.js
+++ b/meshchatx/src/frontend/js/rnode/AndroidBridge.js
@@ -61,28 +61,53 @@ export default class AndroidBridge {
}
/**
- * Request a runtime permission group from Android. Resolves to true if
- * the permission was already granted (or the call was made on a non-
- * android build). The actual grant result is delivered asynchronously
- * by the OS, so callers should re-check via hasPermission afterwards.
+ * Request a runtime permission group from Android.
+ * Resolves to a status string: granted | requested | settings | unsupported | true (legacy).
*/
async requestPermission(permissionGroup) {
if (!this.bridge) {
- return true;
+ return "granted";
}
if (permissionGroup === PERM_BLUETOOTH && typeof this.bridge.requestBluetoothPermissions === "function") {
return safeCall(() => {
- this.bridge.requestBluetoothPermissions();
- return true;
- }, false);
+ const result = this.bridge.requestBluetoothPermissions();
+ if (typeof result === "string") {
+ return result;
+ }
+ return "requested";
+ }, "unsupported");
}
if (permissionGroup === PERM_USB && typeof this.bridge.requestUsbPermissions === "function") {
return safeCall(() => {
- this.bridge.requestUsbPermissions();
- return true;
- }, false);
+ const result = this.bridge.requestUsbPermissions();
+ if (typeof result === "string") {
+ return result;
+ }
+ return "requested";
+ }, "unsupported");
}
- return false;
+ return "unsupported";
+ }
+
+ hasAndroidSerial() {
+ return this.hasNativeRNodeFlasher();
+ }
+
+ hasNativeRNodeFlasher() {
+ if (!this.bridge || typeof this.bridge.hasNativeRNodeFlasher !== "function") {
+ return false;
+ }
+ return safeCall(() => Boolean(this.bridge.hasNativeRNodeFlasher()), false);
+ }
+
+ openRNodeFlasher() {
+ if (!this.bridge || typeof this.bridge.openRNodeFlasher !== "function") {
+ return false;
+ }
+ return safeCall(() => {
+ this.bridge.openRNodeFlasher();
+ return true;
+ }, false);
}
openBluetoothSettings() {
@@ -185,6 +210,44 @@ export default class AndroidBridge {
return true;
}, false);
}
+
+ /**
+ * Configured remote backend URL, or empty string for the on-device local backend.
+ */
+ getRemoteBackendUrl() {
+ if (!this.bridge || typeof this.bridge.getRemoteBackendUrl !== "function") {
+ return "";
+ }
+ return safeCall(() => String(this.bridge.getRemoteBackendUrl() || ""), "");
+ }
+
+ getEffectiveBackendUrl() {
+ if (!this.bridge || typeof this.bridge.getEffectiveBackendUrl !== "function") {
+ return null;
+ }
+ return safeCall(() => this.bridge.getEffectiveBackendUrl(), null);
+ }
+
+ isRemoteBackend() {
+ if (!this.bridge || typeof this.bridge.isRemoteBackend !== "function") {
+ return false;
+ }
+ return safeCall(() => Boolean(this.bridge.isRemoteBackend()), false);
+ }
+
+ /**
+ * Persist remote backend URL and restart the Android shell.
+ * Empty string clears to local. Returns ok | invalid | unchanged | unsupported.
+ */
+ setRemoteBackendUrlAndRestart(url) {
+ if (!this.bridge || typeof this.bridge.setRemoteBackendUrlAndRestart !== "function") {
+ return "unsupported";
+ }
+ return safeCall(() => {
+ const result = this.bridge.setRemoteBackendUrlAndRestart(url == null ? "" : String(url));
+ return typeof result === "string" ? result : "unsupported";
+ }, "unsupported");
+ }
}
AndroidBridge.PERM_BLUETOOTH = PERM_BLUETOOTH;
diff --git a/meshchatx/src/frontend/js/rnode/Capabilities.js b/meshchatx/src/frontend/js/rnode/Capabilities.js
index 154203c2..6553be8c 100644
--- a/meshchatx/src/frontend/js/rnode/Capabilities.js
+++ b/meshchatx/src/frontend/js/rnode/Capabilities.js
@@ -47,7 +47,19 @@ function detectSerial(env, platform) {
const hasNative = Boolean(env.navigator?.serial);
const hasUsbPolyfillTarget = Boolean(env.navigator?.usb);
const hasPolyfillModule = Boolean(env.serial);
+ const hasNativeFlasher =
+ Boolean(env.MeshChatXAndroid)
+ && typeof env.MeshChatXAndroid.hasNativeRNodeFlasher === "function"
+ && Boolean(env.MeshChatXAndroid.hasNativeRNodeFlasher());
+ if (hasNativeFlasher) {
+ return {
+ available: true,
+ kind: "android-native-activity",
+ polyfilled: false,
+ reason: null,
+ };
+ }
if (hasNative) {
return {
available: true,
@@ -98,10 +110,20 @@ function detectBluetooth(env, platform) {
};
}
if (platform.hasMeshChatXAndroid) {
+ const bridge = env.MeshChatXAndroid;
+ const hasPerms =
+ typeof bridge?.hasBluetoothPermissions === "function"
+ ? Boolean(bridge.hasBluetoothPermissions())
+ : false;
return {
+ // WebView still has no Web Bluetooth GATT. Permissions matter for
+ // mesh RNode BLE and OS pairing. Keep transport disabled for flash
+ // selection, but surface a clearer reason + actions.
available: false,
kind: "android-bridge",
- reason: "android_bridge_not_implemented",
+ reason: hasPerms
+ ? "android_bridge_no_web_bluetooth"
+ : "android_bluetooth_permission_required",
};
}
return {
@@ -174,7 +196,12 @@ export function transportSuggestionKeys(capabilities, transportName) {
const reason = transport.reason ?? "unknown";
const suggestions = [`tools.rnode_flasher.support.${transportName}.${reason}`];
if (transportName === TRANSPORT_SERIAL && platform.isAndroid) {
- suggestions.push("tools.rnode_flasher.support.serial.android_use_chrome");
+ if (reason === "android_webview_no_serial") {
+ suggestions.push("tools.rnode_flasher.support.serial.android_use_chrome");
+ }
+ }
+ if (transportName === TRANSPORT_SERIAL && reason === "android_permission_required") {
+ suggestions.push("tools.rnode_flasher.support.serial.android_request_usb");
}
if (transportName === TRANSPORT_BLUETOOTH && !platform.isSecureContext) {
suggestions.push("tools.rnode_flasher.support.bluetooth.requires_https");
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index 22d6acb5..d75dc241 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -2551,7 +2551,8 @@
"android_webview_no_serial": "Android unterstützt Web Serial in dieser Ansicht nicht. Verwenden Sie einen Desktop-Browser oder den integrierten Flasher.",
"browser_unsupported": "Dieser Browser unterstützt Web Serial nicht. Versuchen Sie Chrome, Edge oder einen anderen Chromium-basierten Browser.",
"android_use_chrome": "Öffnen Sie diese Seite unter Android in Chrome, um Web Serial über WebUSB zu nutzen.",
- "unknown": "USB-Serial kann in dieser Umgebung nicht initialisiert werden."
+ "unknown": "USB-Serial kann in dieser Umgebung nicht initialisiert werden.",
+ "android_request_usb": "Öffne den nativen Flasher, um USB zu erlauben und über die serielle Schnittstelle zu flashen."
},
"bluetooth": {
"title": "Bluetooth ist nicht verfügbar",
@@ -2559,14 +2560,25 @@
"browser_unsupported": "Dieser Browser unterstützt Web Bluetooth nicht. Versuchen Sie Chrome oder Edge.",
"insecure_context": "Web Bluetooth erfordert einen sicheren Kontext (HTTPS oder localhost).",
"requires_https": "Öffnen Sie den Flasher über HTTPS oder localhost, um Bluetooth zu aktivieren.",
- "unknown": "Bluetooth kann in dieser Umgebung nicht initialisiert werden."
+ "unknown": "Bluetooth kann in dieser Umgebung nicht initialisiert werden.",
+ "android_bridge_no_web_bluetooth": "Bluetooth-Berechtigung erteilt. Flashen Sie über USB im nativen Flasher (Web Bluetooth ist in der WebView nicht verfügbar).",
+ "android_bluetooth_permission_required": "Für Mesh-RNode-BLE ist die Bluetooth-Berechtigung erforderlich. Tippen Sie auf Bluetooth erlauben."
},
"actions": {
"load_polyfill": "Polyfill laden",
"request_bluetooth": "Bluetooth erlauben",
"open_settings": "Einstellungen öffnen",
"polyfill_loading": "USB-Serial-Polyfill wird geladen...",
- "bluetooth_requested": "Bluetooth-Berechtigung angefordert."
+ "bluetooth_requested": "Bluetooth-Berechtigung angefordert.",
+ "request_usb": "USB erlauben",
+ "open_native": "Nativen Flasher öffnen",
+ "opened_native": "Nativer RNode-Flasher geöffnet.",
+ "bluetooth_already_granted": "Bluetooth-Berechtigung ist bereits erteilt.",
+ "bluetooth_open_settings": "Öffnen Sie die App-Einstellungen und aktivieren Sie Bluetooth-Berechtigungen.",
+ "bluetooth_unsupported": "Anfrage der Bluetooth-Berechtigung ist nicht verfügbar.",
+ "bluetooth_granted": "Bluetooth-Berechtigung erteilt.",
+ "bluetooth_denied": "Bluetooth-Berechtigung verweigert.",
+ "usb_requested": "USB-Berechtigung angefordert."
}
},
"diagnostics": {
@@ -3366,6 +3378,16 @@
"android_clear_clipboard_on_background_on": "Zwischenablage wird beim Wechsel in den Hintergrund geleert",
"android_clear_clipboard_on_background_off": "Zwischenablage wird beim Hintergrundwechsel nicht mehr geleert",
"android_privacy_save_failed": "Android-Datenschutzeinstellung konnte nicht aktualisiert werden.",
+ "android_remote_backend_heading": "URL des entfernten Backends",
+ "android_remote_backend_desc": "Verbinde diese Telefon-App mit MeshChatX auf deinem Desktop oder einem Server im gleichen Netzwerk. Leer lassen, um das geräteeigene Backend zu nutzen. Eine Änderung startet die App neu.",
+ "android_remote_backend_placeholder": "https://192.168.1.10:9337",
+ "android_remote_backend_apply": "Übernehmen und neu starten",
+ "android_remote_backend_use_local": "Geräteeigenes Backend nutzen",
+ "android_remote_backend_active": "Entferntes Backend aktiv: {url}",
+ "android_remote_backend_invalid": "Gib eine gültige http(s)-URL ein oder lass das Feld leer für das geräteeigene Backend.",
+ "android_remote_backend_unchanged": "Die Backend-URL ist bereits auf diesen Wert gesetzt.",
+ "android_remote_backend_already_local": "Es wird bereits das geräteeigene Backend verwendet.",
+ "android_remote_backend_restarting": "Neustart, um die Backend-URL zu übernehmen…",
"nomad_micron_wasm_title": "Micron (WASM)",
"nomad_micron_wasm_desc_before_link": "Optional WASM renderer (",
"nomad_micron_wasm_link_label": "micron-parser-go",
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 80a8b8b3..3c71a827 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -1933,6 +1933,16 @@
"android_clear_clipboard_on_background_on": "Clipboard will clear when the app backgrounds",
"android_clear_clipboard_on_background_off": "Clipboard will no longer clear on background",
"android_privacy_save_failed": "Could not update Android privacy setting.",
+ "android_remote_backend_heading": "Remote backend URL",
+ "android_remote_backend_desc": "Point this phone app at MeshChatX running on your desktop or a server on the same network. Leave empty to use the on-device backend. Changing this restarts the app.",
+ "android_remote_backend_placeholder": "https://192.168.1.10:9337",
+ "android_remote_backend_apply": "Apply and restart",
+ "android_remote_backend_use_local": "Use on-device backend",
+ "android_remote_backend_active": "Using remote backend: {url}",
+ "android_remote_backend_invalid": "Enter a valid http(s) URL, or leave blank for the on-device backend.",
+ "android_remote_backend_unchanged": "Backend URL is already set to that value.",
+ "android_remote_backend_already_local": "Already using the on-device backend.",
+ "android_remote_backend_restarting": "Restarting to apply backend URL…",
"battery": {
"eyebrow": "Power",
"title": "Battery saver",
@@ -2748,14 +2758,17 @@
"serial": {
"title": "USB serial is unavailable",
"polyfill_not_loaded": "USB serial polyfill has not been loaded yet.",
- "android_webview_no_serial": "Android does not support Web Serial in this view. Use a desktop browser or the built-in flasher.",
+ "android_webview_no_serial": "Use the native RNode Flasher on Android for USB flashing.",
+ "android_request_usb": "Open the native flasher to allow USB and flash over serial.",
"browser_unsupported": "This browser does not support Web Serial. Try Chrome, Edge, or another Chromium-based browser.",
- "android_use_chrome": "On Android, opening this page in Chrome lets you use Web Serial via WebUSB.",
+ "android_use_chrome": "Desktop browsers can use Web Serial. On Android, use the native flasher.",
"unknown": "USB serial cannot be initialised in this environment."
},
"bluetooth": {
"title": "Bluetooth is unavailable",
- "android_bridge_not_implemented": "Web Bluetooth is not available here. Use the OS Bluetooth settings or a different browser.",
+ "android_bridge_not_implemented": "Web Bluetooth is not available in WebView. Use the native flasher for USB, and Allow Bluetooth for mesh RNode BLE.",
+ "android_bridge_no_web_bluetooth": "Bluetooth permission is granted. Flash over USB in the native flasher (Web Bluetooth is not available in WebView).",
+ "android_bluetooth_permission_required": "Bluetooth permission is required for mesh RNode BLE. Tap Allow Bluetooth.",
"browser_unsupported": "This browser does not support Web Bluetooth. Try Chrome or Edge.",
"insecure_context": "Web Bluetooth requires a secure context (HTTPS or localhost).",
"requires_https": "Open the flasher over HTTPS or via localhost to enable Bluetooth.",
@@ -2764,9 +2777,18 @@
"actions": {
"load_polyfill": "Load polyfill",
"request_bluetooth": "Allow Bluetooth",
+ "request_usb": "Allow USB",
+ "open_native": "Open native flasher",
"open_settings": "Open settings",
"polyfill_loading": "Loading USB serial polyfill...",
- "bluetooth_requested": "Bluetooth permission requested."
+ "opened_native": "Opened the native RNode flasher.",
+ "bluetooth_requested": "Bluetooth permission requested.",
+ "bluetooth_already_granted": "Bluetooth permission is already granted.",
+ "bluetooth_open_settings": "Open app settings and enable Bluetooth permissions.",
+ "bluetooth_unsupported": "Bluetooth permission request is unavailable.",
+ "bluetooth_granted": "Bluetooth permission granted.",
+ "bluetooth_denied": "Bluetooth permission denied.",
+ "usb_requested": "USB permission requested."
}
},
"diagnostics": {
diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index 0d4da48f..0953ac53 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -1914,6 +1914,16 @@
"android_clear_clipboard_on_background_on": "El portapapeles se vaciará al pasar a segundo plano",
"android_clear_clipboard_on_background_off": "El portapapeles ya no se vaciará en segundo plano",
"android_privacy_save_failed": "No se pudo actualizar el ajuste de privacidad de Android.",
+ "android_remote_backend_heading": "URL del backend remoto",
+ "android_remote_backend_desc": "Apunta esta app del teléfono a MeshChatX en tu escritorio o en un servidor de la misma red. Déjalo vacío para usar el backend del dispositivo. Cambiarlo reinicia la app.",
+ "android_remote_backend_placeholder": "https://192.168.1.10:9337",
+ "android_remote_backend_apply": "Aplicar y reiniciar",
+ "android_remote_backend_use_local": "Usar backend del dispositivo",
+ "android_remote_backend_active": "Usando backend remoto: {url}",
+ "android_remote_backend_invalid": "Introduce una URL http(s) válida, o déjala en blanco para el backend del dispositivo.",
+ "android_remote_backend_unchanged": "La URL del backend ya está configurada con ese valor.",
+ "android_remote_backend_already_local": "Ya se está usando el backend del dispositivo.",
+ "android_remote_backend_restarting": "Reiniciando para aplicar la URL del backend…",
"nomad_micron_wasm_title": "Micron (WASM)",
"nomad_micron_wasm_desc_before_link": "Optional WASM renderer (",
"nomad_micron_wasm_link_label": "micron-parser-go",
@@ -2747,7 +2757,8 @@
"android_webview_no_serial": "Android no admite Web Serial en esta vista. Use un navegador de escritorio o el flasher integrado.",
"browser_unsupported": "Este navegador no soporta Web Serial. Prueba con Chrome, Edge u otro navegador basado en Chromium.",
"android_use_chrome": "En Android, abrir esta página en Chrome permite usar Web Serial a través de WebUSB.",
- "unknown": "USB serial no se puede inicializar en este entorno."
+ "unknown": "USB serial no se puede inicializar en este entorno.",
+ "android_request_usb": "Abre el flasher nativo para permitir USB y flashear por serie."
},
"bluetooth": {
"title": "Bluetooth no disponible",
@@ -2755,14 +2766,25 @@
"browser_unsupported": "Este navegador no soporta Web Bluetooth. Pruebe con Chrome o Edge.",
"insecure_context": "Web Bluetooth requiere un contexto seguro (HTTPS o localhost).",
"requires_https": "Abra el flasher a través de HTTPS o localhost para habilitar Bluetooth.",
- "unknown": "Bluetooth no se puede inicializar en este entorno."
+ "unknown": "Bluetooth no se puede inicializar en este entorno.",
+ "android_bridge_no_web_bluetooth": "Permiso de Bluetooth concedido. Flashea por USB en el flasher nativo (Web Bluetooth no está disponible en WebView).",
+ "android_bluetooth_permission_required": "Se requiere permiso de Bluetooth para RNode BLE de malla. Toca Permitir Bluetooth."
},
"actions": {
"load_polyfill": "Cargar polyfill",
"request_bluetooth": "Permitir Bluetooth",
"open_settings": "Abrir configuración",
"polyfill_loading": "Cargando polyfill de USB serial...",
- "bluetooth_requested": "Permiso de Bluetooth solicitado."
+ "bluetooth_requested": "Permiso de Bluetooth solicitado.",
+ "request_usb": "Permitir USB",
+ "open_native": "Abrir flasher nativo",
+ "opened_native": "Se abrió el flasher nativo de RNode.",
+ "bluetooth_already_granted": "El permiso de Bluetooth ya está concedido.",
+ "bluetooth_open_settings": "Abre la configuración de la app y activa los permisos de Bluetooth.",
+ "bluetooth_unsupported": "La solicitud de permiso de Bluetooth no está disponible.",
+ "bluetooth_granted": "Permiso de Bluetooth concedido.",
+ "bluetooth_denied": "Permiso de Bluetooth denegado.",
+ "usb_requested": "Permiso USB solicitado."
}
},
"diagnostics": {
diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index cfb015a1..c45543a6 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -1930,6 +1930,16 @@
"android_clear_clipboard_on_background_on": "Leikepöytä tyhjennetään taustalle siirryttäessä",
"android_clear_clipboard_on_background_off": "Leikepöytää ei enää tyhjennetä taustalle siirryttäessä",
"android_privacy_save_failed": "Android-yksityisyysasetusta ei voitu päivittää.",
+ "android_remote_backend_heading": "Etätaustajärjestelmän URL",
+ "android_remote_backend_desc": "Ohjaa tämä puhelinsovellus työpöydälläsi tai saman verkon palvelimella olevaan MeshChatX:ään. Jätä tyhjäksi käyttääksesi laitteen omaa taustajärjestelmää. Muutos käynnistää sovelluksen uudelleen.",
+ "android_remote_backend_placeholder": "https://192.168.1.10:9337",
+ "android_remote_backend_apply": "Käytä ja käynnistä uudelleen",
+ "android_remote_backend_use_local": "Käytä laitteen omaa taustajärjestelmää",
+ "android_remote_backend_active": "Käytössä etätaustajärjestelmä: {url}",
+ "android_remote_backend_invalid": "Anna kelvollinen http(s)-URL tai jätä tyhjäksi laitteen omaa taustajärjestelmää varten.",
+ "android_remote_backend_unchanged": "Taustajärjestelmän URL on jo asetettu tähän arvoon.",
+ "android_remote_backend_already_local": "Laitteen oma taustajärjestelmä on jo käytössä.",
+ "android_remote_backend_restarting": "Käynnistetään uudelleen taustajärjestelmän URL:n käyttöönottoa varten…",
"nomad_micron_wasm_title": "Micron (WASM)",
"nomad_micron_wasm_desc_before_link": "Käytä nopeampaa moottoria (",
"nomad_micron_wasm_link_label": "micron-parser-go",
@@ -2747,7 +2757,8 @@
"android_webview_no_serial": "Android ei tue Web Serialia tässä näkymässä. Käytä työpöytäselainta tai sisäänrakennettua flasheria.",
"browser_unsupported": "Tämä selain ei tue Web Serialia. Kokeile Chromea, Edgeä tai muuta Chromium-pohjaista selainta.",
"android_use_chrome": "Androidilla tämän sivun avaaminen Chromessa mahdollistaa Web Serialin käytön WebUSB:n kautta.",
- "unknown": "USB-sarjaporttia ei voida alustaa tässä ympäristössä."
+ "unknown": "USB-sarjaporttia ei voida alustaa tässä ympäristössä.",
+ "android_request_usb": "Avaa natiivi flasher salliaksesi USB:n ja flashataksesi sarjaportin kautta."
},
"bluetooth": {
"title": "Bluetooth ei ole käytettävissä",
@@ -2755,14 +2766,25 @@
"browser_unsupported": "Tämä selain ei tue Web Bluetoothia. Kokeile Chromea tai Edgeä.",
"insecure_context": "Web Bluetooth vaatii suojatun yhteyden (HTTPS tai localhost).",
"requires_https": "Avaa flasher HTTPS:n tai localhostin kautta ottaaksesi Bluetoothin käyttöön.",
- "unknown": "Bluetoothia ei voida alustaa tässä ympäristössä."
+ "unknown": "Bluetoothia ei voida alustaa tässä ympäristössä.",
+ "android_bridge_no_web_bluetooth": "Bluetooth-lupa myönnetty. Flashaa USB:llä natiivissa flasherissa (Web Bluetooth ei ole käytettävissä WebViewissä).",
+ "android_bluetooth_permission_required": "Mesh-RNode BLE vaatii Bluetooth-luvan. Napauta Salli Bluetooth."
},
"actions": {
"load_polyfill": "Lataa polyfill",
"request_bluetooth": "Salli Bluetooth",
"open_settings": "Avaa asetukset",
"polyfill_loading": "Ladataan USB-sarjaportin polyfilliä...",
- "bluetooth_requested": "Bluetooth-lupaa pyydetty."
+ "bluetooth_requested": "Bluetooth-lupaa pyydetty.",
+ "request_usb": "Salli USB",
+ "open_native": "Avaa natiivi flasher",
+ "opened_native": "Natiivi RNode-flasher avattu.",
+ "bluetooth_already_granted": "Bluetooth-lupa on jo myönnetty.",
+ "bluetooth_open_settings": "Avaa sovelluksen asetukset ja ota Bluetooth-luvat käyttöön.",
+ "bluetooth_unsupported": "Bluetooth-lupapyyntö ei ole käytettävissä.",
+ "bluetooth_granted": "Bluetooth-lupa myönnetty.",
+ "bluetooth_denied": "Bluetooth-lupa evätty.",
+ "usb_requested": "USB-lupaa pyydetty."
}
},
"diagnostics": {
diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index 525ef58e..8c6f75ad 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -1914,6 +1914,16 @@
"android_clear_clipboard_on_background_on": "Le presse-papiers sera vidé en arrière-plan",
"android_clear_clipboard_on_background_off": "Le presse-papiers ne sera plus vidé en arrière-plan",
"android_privacy_save_failed": "Impossible de mettre à jour le réglage de confidentialité Android.",
+ "android_remote_backend_heading": "URL du backend distant",
+ "android_remote_backend_desc": "Pointez cette application téléphone vers MeshChatX sur votre bureau ou un serveur du même réseau. Laissez vide pour utiliser le backend de l'appareil. Un changement redémarre l'application.",
+ "android_remote_backend_placeholder": "https://192.168.1.10:9337",
+ "android_remote_backend_apply": "Appliquer et redémarrer",
+ "android_remote_backend_use_local": "Utiliser le backend de l'appareil",
+ "android_remote_backend_active": "Backend distant utilisé : {url}",
+ "android_remote_backend_invalid": "Saisissez une URL http(s) valide, ou laissez vide pour le backend de l'appareil.",
+ "android_remote_backend_unchanged": "L'URL du backend est déjà définie sur cette valeur.",
+ "android_remote_backend_already_local": "Le backend de l'appareil est déjà utilisé.",
+ "android_remote_backend_restarting": "Redémarrage pour appliquer l'URL du backend…",
"nomad_micron_wasm_title": "Micron (WASM)",
"nomad_micron_wasm_desc_before_link": "Optional WASM renderer (",
"nomad_micron_wasm_link_label": "micron-parser-go",
@@ -2747,7 +2757,8 @@
"android_webview_no_serial": "Android ne prend pas en charge Web Serial dans cette vue. Utilisez un navigateur de bureau ou le flasher intégré.",
"browser_unsupported": "Ce navigateur ne prend pas en charge Web Serial. Essayez Chrome, Edge ou un autre navigateur basé sur Chromium.",
"android_use_chrome": "Sur Android, ouvrir cette page dans Chrome permet d'utiliser Web Serial via WebUSB.",
- "unknown": "L'USB série ne peut pas être initialisé dans cet environnement."
+ "unknown": "L'USB série ne peut pas être initialisé dans cet environnement.",
+ "android_request_usb": "Ouvrez le flasher natif pour autoriser l'USB et flasher en série."
},
"bluetooth": {
"title": "Bluetooth indisponible",
@@ -2755,14 +2766,25 @@
"browser_unsupported": "Ce navigateur ne prend pas en charge Web Bluetooth. Essayez Chrome ou Edge.",
"insecure_context": "Web Bluetooth nécessite un contexte sécurisé (HTTPS ou localhost).",
"requires_https": "Ouvrez le flasher via HTTPS ou localhost pour activer le Bluetooth.",
- "unknown": "Le Bluetooth ne peut pas être initialisé dans cet environnement."
+ "unknown": "Le Bluetooth ne peut pas être initialisé dans cet environnement.",
+ "android_bridge_no_web_bluetooth": "Permission Bluetooth accordée. Flashez via USB dans le flasher natif (Web Bluetooth n'est pas disponible dans la WebView).",
+ "android_bluetooth_permission_required": "La permission Bluetooth est requise pour le BLE RNode maillé. Appuyez sur Autoriser le Bluetooth."
},
"actions": {
"load_polyfill": "Charger le polyfill",
"request_bluetooth": "Autoriser le Bluetooth",
"open_settings": "Ouvrir les paramètres",
"polyfill_loading": "Chargement du polyfill USB série...",
- "bluetooth_requested": "Permission Bluetooth demandée."
+ "bluetooth_requested": "Permission Bluetooth demandée.",
+ "request_usb": "Autoriser l'USB",
+ "open_native": "Ouvrir le flasher natif",
+ "opened_native": "Flasher RNode natif ouvert.",
+ "bluetooth_already_granted": "La permission Bluetooth est déjà accordée.",
+ "bluetooth_open_settings": "Ouvrez les paramètres de l'application et activez les permissions Bluetooth.",
+ "bluetooth_unsupported": "La demande de permission Bluetooth est indisponible.",
+ "bluetooth_granted": "Permission Bluetooth accordée.",
+ "bluetooth_denied": "Permission Bluetooth refusée.",
+ "usb_requested": "Permission USB demandée."
}
},
"diagnostics": {
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index e4d1d210..ec9136ef 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -1966,6 +1966,16 @@
"android_clear_clipboard_on_background_on": "Gli appunti verranno svuotati in background",
"android_clear_clipboard_on_background_off": "Gli appunti non verranno più svuotati in background",
"android_privacy_save_failed": "Impossibile aggiornare l'impostazione privacy Android.",
+ "android_remote_backend_heading": "URL del backend remoto",
+ "android_remote_backend_desc": "Collega questa app del telefono a MeshChatX sul desktop o su un server della stessa rete. Lascia vuoto per usare il backend sul dispositivo. La modifica riavvia l'app.",
+ "android_remote_backend_placeholder": "https://192.168.1.10:9337",
+ "android_remote_backend_apply": "Applica e riavvia",
+ "android_remote_backend_use_local": "Usa il backend sul dispositivo",
+ "android_remote_backend_active": "Backend remoto in uso: {url}",
+ "android_remote_backend_invalid": "Inserisci un URL http(s) valido, oppure lascia vuoto per il backend sul dispositivo.",
+ "android_remote_backend_unchanged": "L'URL del backend è già impostato su quel valore.",
+ "android_remote_backend_already_local": "È già in uso il backend sul dispositivo.",
+ "android_remote_backend_restarting": "Riavvio per applicare l'URL del backend…",
"nomad_micron_wasm_title": "Micron (WASM)",
"nomad_micron_wasm_desc_before_link": "Optional WASM renderer (",
"nomad_micron_wasm_link_label": "micron-parser-go",
@@ -2799,7 +2809,8 @@
"android_webview_no_serial": "Android non supporta Web Serial in questa vista. Usa un browser desktop o il flasher integrato.",
"browser_unsupported": "Questo browser non supporta Web Serial. Prova Chrome, Edge o un altro browser basato su Chromium.",
"android_use_chrome": "Su Android, apri questa pagina in Chrome per utilizzare Web Serial tramite WebUSB.",
- "unknown": "USB seriale non può essere inizializzato in questo ambiente."
+ "unknown": "USB seriale non può essere inizializzato in questo ambiente.",
+ "android_request_usb": "Apri il flasher nativo per consentire USB e flashare in seriale."
},
"bluetooth": {
"title": "Bluetooth non disponibile",
@@ -2807,14 +2818,25 @@
"browser_unsupported": "Questo browser non supporta Web Bluetooth. Prova Chrome o Edge.",
"insecure_context": "Web Bluetooth richiede un contesto sicuro (HTTPS o localhost).",
"requires_https": "Apri il flasher tramite HTTPS o localhost per abilitare il Bluetooth.",
- "unknown": "Bluetooth non può essere inizializzato in questo ambiente."
+ "unknown": "Bluetooth non può essere inizializzato in questo ambiente.",
+ "android_bridge_no_web_bluetooth": "Autorizzazione Bluetooth concessa. Flasha via USB nel flasher nativo (Web Bluetooth non è disponibile in WebView).",
+ "android_bluetooth_permission_required": "È richiesta l'autorizzazione Bluetooth per RNode BLE mesh. Tocca Consenti Bluetooth."
},
"actions": {
"load_polyfill": "Carica polyfill",
"request_bluetooth": "Consenti Bluetooth",
"open_settings": "Apri impostazioni",
"polyfill_loading": "Caricamento polyfill USB seriale...",
- "bluetooth_requested": "Autorizzazione Bluetooth richiesta."
+ "bluetooth_requested": "Autorizzazione Bluetooth richiesta.",
+ "request_usb": "Consenti USB",
+ "open_native": "Apri flasher nativo",
+ "opened_native": "Flasher nativo RNode aperto.",
+ "bluetooth_already_granted": "L'autorizzazione Bluetooth è già concessa.",
+ "bluetooth_open_settings": "Apri le impostazioni dell'app e abilita i permessi Bluetooth.",
+ "bluetooth_unsupported": "La richiesta di autorizzazione Bluetooth non è disponibile.",
+ "bluetooth_granted": "Autorizzazione Bluetooth concessa.",
+ "bluetooth_denied": "Autorizzazione Bluetooth negata.",
+ "usb_requested": "Autorizzazione USB richiesta."
}
},
"diagnostics": {
diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 83549109..6f08644a 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -1914,6 +1914,16 @@
"android_clear_clipboard_on_background_on": "Klembord wordt gewist bij achtergrond",
"android_clear_clipboard_on_background_off": "Klembord wordt niet meer gewist bij achtergrond",
"android_privacy_save_failed": "Android-privacy-instelling kon niet worden bijgewerkt.",
+ "android_remote_backend_heading": "URL van externe backend",
+ "android_remote_backend_desc": "Laat deze telefoon-app verbinden met MeshChatX op je desktop of een server in hetzelfde netwerk. Laat leeg om de backend op het apparaat te gebruiken. Een wijziging herstart de app.",
+ "android_remote_backend_placeholder": "https://192.168.1.10:9337",
+ "android_remote_backend_apply": "Toepassen en herstarten",
+ "android_remote_backend_use_local": "Backend op het apparaat gebruiken",
+ "android_remote_backend_active": "Externe backend in gebruik: {url}",
+ "android_remote_backend_invalid": "Voer een geldige http(s)-URL in, of laat leeg voor de backend op het apparaat.",
+ "android_remote_backend_unchanged": "De backend-URL staat al op die waarde.",
+ "android_remote_backend_already_local": "De backend op het apparaat wordt al gebruikt.",
+ "android_remote_backend_restarting": "Opnieuw starten om de backend-URL toe te passen…",
"nomad_micron_wasm_title": "Micron (WASM)",
"nomad_micron_wasm_desc_before_link": "Optional WASM renderer (",
"nomad_micron_wasm_link_label": "micron-parser-go",
@@ -2747,7 +2757,8 @@
"android_webview_no_serial": "Android ondersteunt Web Serial niet in deze weergave. Gebruik een desktopbrowser of de ingebouwde flasher.",
"browser_unsupported": "Deze browser ondersteunt Web Serial niet. Probeer Chrome, Edge of een andere Chromium-gebaseerde browser.",
"android_use_chrome": "Open deze pagina op Android in Chrome om Web Serial via WebUSB te gebruiken.",
- "unknown": "USB-serieel kan niet worden geïnitialiseerd in deze omgeving."
+ "unknown": "USB-serieel kan niet worden geïnitialiseerd in deze omgeving.",
+ "android_request_usb": "Open de native flasher om USB toe te staan en via serieel te flashen."
},
"bluetooth": {
"title": "Bluetooth is niet beschikbaar",
@@ -2755,14 +2766,25 @@
"browser_unsupported": "Deze browser ondersteunt Web Bluetooth niet. Probeer Chrome of Edge.",
"insecure_context": "Web Bluetooth vereist een beveiligde context (HTTPS of localhost).",
"requires_https": "Open de flasher via HTTPS of localhost om Bluetooth in te schakelen.",
- "unknown": "Bluetooth kan niet worden geïnitialiseerd in deze omgeving."
+ "unknown": "Bluetooth kan niet worden geïnitialiseerd in deze omgeving.",
+ "android_bridge_no_web_bluetooth": "Bluetooth-toestemming verleend. Flash via USB in de native flasher (Web Bluetooth is niet beschikbaar in WebView).",
+ "android_bluetooth_permission_required": "Bluetooth-toestemming is vereist voor mesh RNode BLE. Tik op Bluetooth toestaan."
},
"actions": {
"load_polyfill": "Polyfill laden",
"request_bluetooth": "Bluetooth toestaan",
"open_settings": "Instellingen openen",
"polyfill_loading": "USB-serieel polyfill laden...",
- "bluetooth_requested": "Bluetooth-toestemming aangevraagd."
+ "bluetooth_requested": "Bluetooth-toestemming aangevraagd.",
+ "request_usb": "USB toestaan",
+ "open_native": "Native flasher openen",
+ "opened_native": "Native RNode-flasher geopend.",
+ "bluetooth_already_granted": "Bluetooth-toestemming is al verleend.",
+ "bluetooth_open_settings": "Open de app-instellingen en schakel Bluetooth-machtigingen in.",
+ "bluetooth_unsupported": "Bluetooth-toestemmingsverzoek is niet beschikbaar.",
+ "bluetooth_granted": "Bluetooth-toestemming verleend.",
+ "bluetooth_denied": "Bluetooth-toestemming geweigerd.",
+ "usb_requested": "USB-toestemming aangevraagd."
}
},
"diagnostics": {
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index fe94893d..ce31a691 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -2551,7 +2551,8 @@
"android_webview_no_serial": "Android не поддерживает Web Serial в этом режиме. Используйте браузер на ПК или встроенный прошивальщик.",
"browser_unsupported": "Этот браузер не поддерживает Web Serial. Попробуйте Chrome, Edge или другой браузер на основе Chromium.",
"android_use_chrome": "На Android откройте эту страницу в Chrome, чтобы использовать Web Serial через WebUSB.",
- "unknown": "USB-последовательный порт не может быть инициализирован в этом окружении."
+ "unknown": "USB-последовательный порт не может быть инициализирован в этом окружении.",
+ "android_request_usb": "Откройте встроенный прошивальщик, чтобы разрешить USB и прошить через последовательный порт."
},
"bluetooth": {
"title": "Bluetooth недоступен",
@@ -2559,14 +2560,25 @@
"browser_unsupported": "Этот браузер не поддерживает Web Bluetooth. Попробуйте Chrome или Edge.",
"insecure_context": "Web Bluetooth требует безопасного контекста (HTTPS или localhost).",
"requires_https": "Откройте прошивальщик через HTTPS или localhost, чтобы включить Bluetooth.",
- "unknown": "Bluetooth не может быть инициализирован в этом окружении."
+ "unknown": "Bluetooth не может быть инициализирован в этом окружении.",
+ "android_bridge_no_web_bluetooth": "Разрешение Bluetooth выдано. Прошивайте по USB во встроенном прошивальщике (Web Bluetooth недоступен в WebView).",
+ "android_bluetooth_permission_required": "Для mesh RNode BLE требуется разрешение Bluetooth. Нажмите «Разрешить Bluetooth»."
},
"actions": {
"load_polyfill": "Загрузить полифилл",
"request_bluetooth": "Разрешить Bluetooth",
"open_settings": "Открыть настройки",
"polyfill_loading": "Загрузка полифилла USB-последовательного порта...",
- "bluetooth_requested": "Запрошено разрешение Bluetooth."
+ "bluetooth_requested": "Запрошено разрешение Bluetooth.",
+ "request_usb": "Разрешить USB",
+ "open_native": "Открыть встроенный прошивальщик",
+ "opened_native": "Встроенный прошивальщик RNode открыт.",
+ "bluetooth_already_granted": "Разрешение Bluetooth уже выдано.",
+ "bluetooth_open_settings": "Откройте настройки приложения и включите разрешения Bluetooth.",
+ "bluetooth_unsupported": "Запрос разрешения Bluetooth недоступен.",
+ "bluetooth_granted": "Разрешение Bluetooth выдано.",
+ "bluetooth_denied": "В разрешении Bluetooth отказано.",
+ "usb_requested": "Запрошено разрешение USB."
}
},
"diagnostics": {
@@ -3366,6 +3378,16 @@
"android_clear_clipboard_on_background_on": "Буфер обмена будет очищаться при уходе в фон",
"android_clear_clipboard_on_background_off": "Буфер обмена больше не будет очищаться при уходе в фон",
"android_privacy_save_failed": "Не удалось обновить настройку конфиденциальности Android.",
+ "android_remote_backend_heading": "URL удалённого сервера",
+ "android_remote_backend_desc": "Направьте это телефонное приложение на MeshChatX на вашем компьютере или сервере в той же сети. Оставьте пустым, чтобы использовать сервер на устройстве. Изменение перезапускает приложение.",
+ "android_remote_backend_placeholder": "https://192.168.1.10:9337",
+ "android_remote_backend_apply": "Применить и перезапустить",
+ "android_remote_backend_use_local": "Использовать сервер на устройстве",
+ "android_remote_backend_active": "Используется удалённый сервер: {url}",
+ "android_remote_backend_invalid": "Введите действительный URL http(s) или оставьте поле пустым для сервера на устройстве.",
+ "android_remote_backend_unchanged": "URL сервера уже установлен на это значение.",
+ "android_remote_backend_already_local": "Уже используется сервер на устройстве.",
+ "android_remote_backend_restarting": "Перезапуск для применения URL сервера…",
"nomad_micron_wasm_title": "Micron (WASM)",
"nomad_micron_wasm_desc_before_link": "Optional WASM renderer (",
"nomad_micron_wasm_link_label": "micron-parser-go",
diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index 0ac676f0..3f2c4006 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -1914,6 +1914,16 @@
"android_clear_clipboard_on_background_on": "应用进入后台时将清空剪贴板",
"android_clear_clipboard_on_background_off": "应用进入后台时不再清空剪贴板",
"android_privacy_save_failed": "无法更新 Android 隐私设置。",
+ "android_remote_backend_heading": "远程后端 URL",
+ "android_remote_backend_desc": "将此手机应用指向同一网络上桌面或服务器上运行的 MeshChatX。留空则使用设备本地后端。更改后会重启应用。",
+ "android_remote_backend_placeholder": "https://192.168.1.10:9337",
+ "android_remote_backend_apply": "应用并重启",
+ "android_remote_backend_use_local": "使用设备本地后端",
+ "android_remote_backend_active": "正在使用远程后端:{url}",
+ "android_remote_backend_invalid": "请输入有效的 http(s) URL,或留空以使用设备本地后端。",
+ "android_remote_backend_unchanged": "后端 URL 已是该值。",
+ "android_remote_backend_already_local": "已在使用设备本地后端。",
+ "android_remote_backend_restarting": "正在重启以应用后端 URL…",
"nomad_micron_wasm_title": "Micron (WASM)",
"nomad_micron_wasm_desc_before_link": "Optional WASM renderer (",
"nomad_micron_wasm_link_label": "micron-parser-go",
@@ -2747,7 +2757,8 @@
"android_webview_no_serial": "Android 在此视图中不支持 Web Serial。请使用桌面浏览器或内置刷机工具。",
"browser_unsupported": "此浏览器不支持 Web Serial。请尝试 Chrome、Edge 或其他基于 Chromium 的浏览器。",
"android_use_chrome": "在 Android 上,使用 Chrome 打开此页面可通过 WebUSB 使用 Web Serial。",
- "unknown": "在此环境中无法初始化 USB 串口。"
+ "unknown": "在此环境中无法初始化 USB 串口。",
+ "android_request_usb": "打开原生刷写工具以允许 USB 并通过串口刷写。"
},
"bluetooth": {
"title": "蓝牙不可用",
@@ -2755,14 +2766,25 @@
"browser_unsupported": "此浏览器不支持 Web Bluetooth。请尝试 Chrome 或 Edge。",
"insecure_context": "Web Bluetooth 需要安全上下文(HTTPS 或 localhost)。",
"requires_https": "请通过 HTTPS 或 localhost 打开刷写工具以启用蓝牙。",
- "unknown": "在此环境中无法初始化蓝牙。"
+ "unknown": "在此环境中无法初始化蓝牙。",
+ "android_bridge_no_web_bluetooth": "已授予蓝牙权限。请在原生刷写工具中通过 USB 刷写(WebView 中不可用 Web Bluetooth)。",
+ "android_bluetooth_permission_required": "网格 RNode BLE 需要蓝牙权限。请点按“允许蓝牙”。"
},
"actions": {
"load_polyfill": "加载 polyfill",
"request_bluetooth": "允许蓝牙",
"open_settings": "打开设置",
"polyfill_loading": "正在加载 USB 串口 polyfill...",
- "bluetooth_requested": "已请求蓝牙权限。"
+ "bluetooth_requested": "已请求蓝牙权限。",
+ "request_usb": "允许 USB",
+ "open_native": "打开原生刷写工具",
+ "opened_native": "已打开原生 RNode 刷写工具。",
+ "bluetooth_already_granted": "已授予蓝牙权限。",
+ "bluetooth_open_settings": "打开应用设置并启用蓝牙权限。",
+ "bluetooth_unsupported": "无法请求蓝牙权限。",
+ "bluetooth_granted": "已授予蓝牙权限。",
+ "bluetooth_denied": "蓝牙权限被拒绝。",
+ "usb_requested": "已请求 USB 权限。"
}
},
"diagnostics": {
diff --git a/tests/backend/test_android_codec2.py b/tests/backend/test_android_codec2.py
index e076399f..eeab8218 100644
--- a/tests/backend/test_android_codec2.py
+++ b/tests/backend/test_android_codec2.py
@@ -23,6 +23,7 @@ def test_ensure_codec2_loads_bundled_library(tmp_path):
with (
patch.object(android_codec2, "_is_chaquopy_android", return_value=True),
+ patch.object(android_codec2, "_java_system_load_library", return_value=False),
patch.object(
android_codec2,
"_cdll_load",
@@ -40,6 +41,19 @@ def test_ensure_codec2_loads_bundled_library(tmp_path):
assert cdll.call_args_list[1].args[0] == str(lib)
+def test_ensure_codec2_prefers_java_load_library():
+ android_codec2.reset_codec2_preload_state_for_tests()
+ with (
+ patch.object(android_codec2, "_is_chaquopy_android", return_value=True),
+ patch.object(android_codec2, "_java_system_load_library", return_value=True) as java_load,
+ patch.object(android_codec2, "_cdll_load") as cdll,
+ ):
+ assert android_codec2.ensure_codec2_native_library() is True
+ java_load.assert_called_once_with("codec2")
+ cdll.assert_not_called()
+ assert android_codec2.codec2_preload_error() is None
+
+
def test_libcodec2_candidates_find_without_importing_pycodec2(tmp_path, monkeypatch):
"""Discovery must not import pycodec2 (extension needs libcodec2 already loaded)."""
site = tmp_path / "site-packages"
diff --git a/tests/frontend/RNodeAndroidBridge.test.js b/tests/frontend/RNodeAndroidBridge.test.js
index 81822922..edfd76c4 100644
--- a/tests/frontend/RNodeAndroidBridge.test.js
+++ b/tests/frontend/RNodeAndroidBridge.test.js
@@ -31,16 +31,24 @@ describe("AndroidBridge", () => {
it("requestPermission triggers the appropriate bridge method", async () => {
const bridge = {
- requestBluetoothPermissions: vi.fn(),
- requestUsbPermissions: vi.fn(),
+ requestBluetoothPermissions: vi.fn().mockReturnValue("requested"),
+ requestUsbPermissions: vi.fn().mockReturnValue("requested"),
};
const ab = new AndroidBridge(bridge, {});
- await ab.requestPermission(AndroidBridge.PERM_BLUETOOTH);
- await ab.requestPermission(AndroidBridge.PERM_USB);
+ await expect(ab.requestPermission(AndroidBridge.PERM_BLUETOOTH)).resolves.toBe("requested");
+ await expect(ab.requestPermission(AndroidBridge.PERM_USB)).resolves.toBe("requested");
expect(bridge.requestBluetoothPermissions).toHaveBeenCalled();
expect(bridge.requestUsbPermissions).toHaveBeenCalled();
});
+ it("hasNativeRNodeFlasher delegates to the bridge", () => {
+ const bridge = { hasNativeRNodeFlasher: vi.fn().mockReturnValue(true), openRNodeFlasher: vi.fn() };
+ const ab = new AndroidBridge(bridge, {});
+ expect(ab.hasNativeRNodeFlasher()).toBe(true);
+ expect(ab.openRNodeFlasher()).toBe(true);
+ expect(bridge.openRNodeFlasher).toHaveBeenCalled();
+ });
+
it("settings helpers return true when bridge accepts the call", () => {
const bridge = {
openBluetoothSettings: vi.fn(),
@@ -114,4 +122,27 @@ describe("AndroidBridge", () => {
expect(bridge.setBlockScreenshots).toHaveBeenCalledWith(true);
expect(bridge.setClearClipboardOnBackground).toHaveBeenCalledWith(true);
});
+
+ it("remote backend helpers default safely when methods are missing", () => {
+ const ab = new AndroidBridge({}, {});
+ expect(ab.getRemoteBackendUrl()).toBe("");
+ expect(ab.getEffectiveBackendUrl()).toBe(null);
+ expect(ab.isRemoteBackend()).toBe(false);
+ expect(ab.setRemoteBackendUrlAndRestart("http://192.168.1.10:9337")).toBe("unsupported");
+ });
+
+ it("remote backend helpers delegate to the bridge", () => {
+ const bridge = {
+ getRemoteBackendUrl: vi.fn().mockReturnValue("http://192.168.1.10:9337"),
+ getEffectiveBackendUrl: vi.fn().mockReturnValue("http://192.168.1.10:9337"),
+ isRemoteBackend: vi.fn().mockReturnValue(true),
+ setRemoteBackendUrlAndRestart: vi.fn().mockReturnValue("ok"),
+ };
+ const ab = new AndroidBridge(bridge, {});
+ expect(ab.getRemoteBackendUrl()).toBe("http://192.168.1.10:9337");
+ expect(ab.getEffectiveBackendUrl()).toBe("http://192.168.1.10:9337");
+ expect(ab.isRemoteBackend()).toBe(true);
+ expect(ab.setRemoteBackendUrlAndRestart("http://10.0.0.2:8000")).toBe("ok");
+ expect(bridge.setRemoteBackendUrlAndRestart).toHaveBeenCalledWith("http://10.0.0.2:8000");
+ });
});
diff --git a/tests/frontend/RNodeCapabilities.test.js b/tests/frontend/RNodeCapabilities.test.js
index 51d1fe2b..408fb055 100644
--- a/tests/frontend/RNodeCapabilities.test.js
+++ b/tests/frontend/RNodeCapabilities.test.js
@@ -44,6 +44,28 @@ describe("Capabilities.detectCapabilities", () => {
expect(caps.platform.isAndroid).toBe(true);
});
+ it("reports android-native serial when MeshChatXAndroid has native flasher", () => {
+ const env = mkEnv({
+ navigator: { userAgent: "Linux; Android 14" },
+ MeshChatXAndroid: { hasNativeRNodeFlasher: () => true },
+ });
+ const caps = detectCapabilities({ env });
+ expect(caps.transports[TRANSPORT_SERIAL].available).toBe(true);
+ expect(caps.transports[TRANSPORT_SERIAL].kind).toBe("android-native-activity");
+ });
+
+ it("reports bluetooth permission required on MeshChatXAndroid without grant", () => {
+ const env = mkEnv({
+ navigator: { userAgent: "Android" },
+ MeshChatXAndroid: { hasBluetoothPermissions: () => false },
+ });
+ const caps = detectCapabilities({ env });
+ expect(caps.transports[TRANSPORT_BLUETOOTH].available).toBe(false);
+ expect(caps.transports[TRANSPORT_BLUETOOTH].reason).toBe(
+ "android_bluetooth_permission_required"
+ );
+ });
+
it("reports bluetooth available when navigator.bluetooth is present", () => {
const env = mkEnv({ navigator: { userAgent: "x", bluetooth: {} } });
const caps = detectCapabilities({ env });
diff --git a/tests/frontend/behaviorContracts.test.js b/tests/frontend/behaviorContracts.test.js
index 9c848656..e4afda3c 100644
--- a/tests/frontend/behaviorContracts.test.js
+++ b/tests/frontend/behaviorContracts.test.js
@@ -94,6 +94,17 @@ describe("behavior contracts: user-visible wiring must stay connected", () => {
expect(src).toMatch(/getBoolean\(\s*PREF_CLEAR_CLIPBOARD_ON_BACKGROUND\s*,\s*false\s*\)/);
});
+ it("Android MainActivity supports configurable remote backend URL", () => {
+ const src = readSource("android/app/src/main/java/com/meshchatx/MainActivity.java");
+ expect(src).toContain("PREF_REMOTE_BACKEND_URL");
+ expect(src).toContain("setRemoteBackendUrlAndRestart");
+ expect(src).toContain("isRemoteBackendMode");
+ expect(src).toContain("offerSwitchToLocalBackend");
+ const util = readSource("android/app/src/main/java/com/meshchatx/RemoteBackendUrl.java");
+ expect(util).toContain("normalize");
+ expect(util).toContain("LOCAL_BACKEND_URL");
+ });
+
it("Android backup of app data is disabled", () => {
const src = readSource("android/app/src/main/AndroidManifest.xml");
expect(src).toMatch(/android:allowBackup\s*=\s*"false"/);
diff --git a/tests/frontend/remoteBackendUrl.test.js b/tests/frontend/remoteBackendUrl.test.js
new file mode 100644
index 00000000..ebff9d0d
--- /dev/null
+++ b/tests/frontend/remoteBackendUrl.test.js
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vitest";
+import {
+ LOCAL_BACKEND_URL,
+ isValidRemoteBackendUrl,
+ normalizeRemoteBackendUrl,
+ resolveEffectiveBackendUrl,
+} from "../../meshchatx/src/frontend/js/remoteBackendUrl.js";
+
+describe("remoteBackendUrl", () => {
+ it("treats empty as local", () => {
+ expect(normalizeRemoteBackendUrl("")).toBeNull();
+ expect(normalizeRemoteBackendUrl(" ")).toBeNull();
+ expect(resolveEffectiveBackendUrl("")).toBe(LOCAL_BACKEND_URL);
+ expect(isValidRemoteBackendUrl("")).toBe(true);
+ });
+
+ it("normalizes http(s) LAN origins", () => {
+ expect(normalizeRemoteBackendUrl("http://192.168.1.10:9337/")).toBe("http://192.168.1.10:9337");
+ expect(normalizeRemoteBackendUrl("HTTPS://Mesh.Example:8443/app/")).toBe(
+ "https://mesh.example:8443/app"
+ );
+ });
+
+ it("rejects unsafe URLs", () => {
+ expect(normalizeRemoteBackendUrl("javascript:alert(1)")).toBeNull();
+ expect(normalizeRemoteBackendUrl("file:///tmp/x")).toBeNull();
+ expect(normalizeRemoteBackendUrl("https://user:pass@192.168.1.10:9337")).toBeNull();
+ expect(isValidRemoteBackendUrl("not a url")).toBe(false);
+ });
+});
diff --git a/tests/frontend/settingsSections.test.js b/tests/frontend/settingsSections.test.js
index 5021109c..989a25a3 100644
--- a/tests/frontend/settingsSections.test.js
+++ b/tests/frontend/settingsSections.test.js
@@ -223,7 +223,7 @@ describe("BlockedSettingsSection", () => {
});
describe("AndroidSettingsSection", () => {
- it("emits privacy updates and share-apk", async () => {
+ it("emits privacy updates, remote backend actions, and share-apk", async () => {
const wrapper = mount(AndroidSettingsSection, {
props: {
visible: true,
@@ -231,19 +231,29 @@ describe("AndroidSettingsSection", () => {
blockScreenshots: false,
clearClipboardOnBackground: false,
},
+ remoteBackendUrl: "",
+ effectiveBackendUrl: "https://127.0.0.1:8000",
+ remoteBackendActive: false,
},
global: {
mocks: { $t: (key) => key },
},
});
expect(wrapper.text()).toContain("settings.android_privacy_heading");
+ expect(wrapper.text()).toContain("settings.android_remote_backend_heading");
const checkboxes = wrapper.findAll('input[type="checkbox"]');
expect(checkboxes).toHaveLength(2);
await checkboxes[0].setValue(true);
await checkboxes[1].setValue(true);
expect(wrapper.emitted("update:blockScreenshots")).toEqual([[true]]);
expect(wrapper.emitted("update:clearClipboardOnBackground")).toEqual([[true]]);
- await wrapper.find("button").trigger("click");
+ const urlInput = wrapper.find('input[type="url"]');
+ await urlInput.setValue("http://192.168.1.10:9337");
+ expect(wrapper.emitted("update:remoteBackendUrl")?.at(-1)).toEqual(["http://192.168.1.10:9337"]);
+ const buttons = wrapper.findAll("button");
+ await buttons[0].trigger("click");
+ expect(wrapper.emitted("apply-remote-backend")).toHaveLength(1);
+ await buttons[2].trigger("click");
expect(wrapper.emitted("share-apk")).toHaveLength(1);
});
});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────